/*
 * 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;

import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Stream;

import org.apache.maven.api.Session;
import org.apache.maven.api.model.Model;
import org.apache.maven.api.model.Prerequisites;
import org.apache.maven.api.model.Profile;
import org.apache.maven.artifact.ArtifactUtils;
import org.apache.maven.execution.BuildResumptionAnalyzer;
import org.apache.maven.execution.BuildResumptionDataRepository;
import org.apache.maven.execution.BuildResumptionPersistenceException;
import org.apache.maven.execution.DefaultMavenExecutionResult;
import org.apache.maven.execution.ExecutionEvent;
import org.apache.maven.execution.MavenExecutionRequest;
import org.apache.maven.execution.MavenExecutionResult;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.execution.ProfileActivation;
import org.apache.maven.execution.ProjectActivation;
import org.apache.maven.execution.ProjectDependencyGraph;
import org.apache.maven.graph.GraphBuilder;
import org.apache.maven.graph.ProjectSelector;
import org.apache.maven.internal.aether.DefaultRepositorySystemSessionFactory;
import org.apache.maven.internal.aether.MavenChainedWorkspaceReader;
import org.apache.maven.internal.impl.DefaultSessionFactory;
import org.apache.maven.internal.impl.InternalSession;
import org.apache.maven.lifecycle.LifecycleExecutionException;
import org.apache.maven.lifecycle.internal.ExecutionEventCatapult;
import org.apache.maven.lifecycle.internal.LifecycleStarter;
import org.apache.maven.model.building.ModelProblem;
import org.apache.maven.model.building.Result;
import org.apache.maven.model.superpom.SuperPomProvider;
import org.apache.maven.plugin.LegacySupport;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.ProjectBuilder;
import org.apache.maven.session.scope.internal.SessionScope;
import org.codehaus.plexus.PlexusContainer;
import org.codehaus.plexus.component.repository.exception.ComponentLookupException;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.RepositorySystemSession.CloseableSession;
import org.eclipse.aether.repository.WorkspaceReader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.helpers.MessageFormatter;

import static java.util.stream.Collectors.toSet;

/**
 */
@Named
@Singleton
public class DefaultMaven implements Maven {
    private final Logger logger = LoggerFactory.getLogger(getClass());

    protected ProjectBuilder projectBuilder;

    private LifecycleStarter lifecycleStarter;

    protected PlexusContainer container;

    private ExecutionEventCatapult eventCatapult;

    private LegacySupport legacySupport;

    private SessionScope sessionScope;

    private DefaultRepositorySystemSessionFactory repositorySessionFactory;

    private final GraphBuilder graphBuilder;

    private final BuildResumptionAnalyzer buildResumptionAnalyzer;

    private final BuildResumptionDataRepository buildResumptionDataRepository;

    private final SuperPomProvider superPomProvider;

    private final DefaultSessionFactory defaultSessionFactory;

    private final ProjectSelector projectSelector;

    @Inject
    @SuppressWarnings("checkstyle:ParameterNumber")
    public DefaultMaven(
            ProjectBuilder projectBuilder,
            LifecycleStarter lifecycleStarter,
            PlexusContainer container,
            ExecutionEventCatapult eventCatapult,
            LegacySupport legacySupport,
            SessionScope sessionScope,
            DefaultRepositorySystemSessionFactory repositorySessionFactory,
            @Named(GraphBuilder.HINT) GraphBuilder graphBuilder,
            BuildResumptionAnalyzer buildResumptionAnalyzer,
            BuildResumptionDataRepository buildResumptionDataRepository,
            SuperPomProvider superPomProvider,
            DefaultSessionFactory defaultSessionFactory) {
        this.projectBuilder = projectBuilder;
        this.lifecycleStarter = lifecycleStarter;
        this.container = container;
        this.eventCatapult = eventCatapult;
        this.legacySupport = legacySupport;
        this.sessionScope = sessionScope;
        this.repositorySessionFactory = repositorySessionFactory;
        this.graphBuilder = graphBuilder;
        this.buildResumptionAnalyzer = buildResumptionAnalyzer;
        this.buildResumptionDataRepository = buildResumptionDataRepository;
        this.superPomProvider = superPomProvider;
        this.defaultSessionFactory = defaultSessionFactory;
        this.projectSelector = new ProjectSelector(); // if necessary switch to DI
    }

    @Override
    public MavenExecutionResult execute(MavenExecutionRequest request) {
        MavenExecutionResult result;

        try {
            result = doExecute(request);
        } catch (OutOfMemoryError e) {
            result = addExceptionToResult(new DefaultMavenExecutionResult(), e);
        } catch (RuntimeException e) {
            // TODO Hack to make the cycle detection the same for the new graph builder
            if (e.getCause() instanceof ProjectCycleException) {
                result = addExceptionToResult(new DefaultMavenExecutionResult(), e.getCause());
            } else {
                result = addExceptionToResult(
                        new DefaultMavenExecutionResult(), new InternalErrorException("Internal error: " + e, e));
            }
        } finally {
            legacySupport.setSession(null);
        }

        return result;
    }

    //
    // 1) Setup initial properties.
    //
    // 2) Validate local repository directory is accessible.
    //
    // 3) Create RepositorySystemSession.
    //
    // 4) Create MavenSession.
    //
    // 5) Execute AbstractLifecycleParticipant.afterSessionStart(session)
    //
    // 6) Get reactor projects looking for general POM errors
    //
    // 7) Create ProjectDependencyGraph using trimming which takes into account --projects and reactor mode.
    // This ensures that the projects passed into the ReactorReader are only those specified.
    //
    // 8) Create ReactorReader with the getProjectMap( projects ). NOTE that getProjectMap(projects) is the code that
    // checks for duplicate projects definitions in the build. Ideally this type of duplicate checking should be
    // part of getting the reactor projects in 6). The duplicate checking is conflated with getProjectMap(projects).
    //
    // 9) Execute AbstractLifecycleParticipant.afterProjectsRead(session)
    //
    // 10) Create ProjectDependencyGraph without trimming (as trimming was done in 7). A new topological sort is
    // required after the execution of 9) as the AbstractLifecycleParticipants are free to mutate the MavenProject
    // instances, which may change dependencies which can, in turn, affect the build order.
    //
    // 11) Execute LifecycleStarter.start()
    //
    @SuppressWarnings("checkstyle:methodlength")
    private MavenExecutionResult doExecute(MavenExecutionRequest request) {
        request.setStartTime(new Date());

        MavenExecutionResult result = new DefaultMavenExecutionResult();

        try {
            validateLocalRepository(request);
        } catch (IOException e) {
            return addExceptionToResult(result, e);
        }

        //
        // We enter the session scope right after the MavenSession creation and before any of the
        // AbstractLifecycleParticipant lookups
        // so that @SessionScoped components can be @Injected into AbstractLifecycleParticipants.
        //
        sessionScope.enter();
        MavenChainedWorkspaceReader chainedWorkspaceReader = new MavenChainedWorkspaceReader();
        try (CloseableSession closeableSession = newCloseableSession(request, chainedWorkspaceReader)) {
            MavenSession session = new MavenSession(closeableSession, request, result);
            session.setSession(defaultSessionFactory.getSession(session));

            sessionScope.seed(MavenSession.class, session);
            sessionScope.seed(Session.class, session.getSession());
            sessionScope.seed(InternalSession.class, InternalSession.from(session.getSession()));

            legacySupport.setSession(session);

            return doExecute(request, session, result, chainedWorkspaceReader);
        } finally {
            sessionScope.exit();
        }
    }

    private MavenExecutionResult doExecute(
            MavenExecutionRequest request,
            MavenSession session,
            MavenExecutionResult result,
            MavenChainedWorkspaceReader chainedWorkspaceReader) {
        try {
            afterSessionStart(session);
        } catch (MavenExecutionException e) {
            return addExceptionToResult(result, e);
        }

        try {
            WorkspaceReader reactorReader = container.lookup(WorkspaceReader.class, ReactorReader.HINT);
            chainedWorkspaceReader.setReaders(Collections.singletonList(reactorReader));
        } catch (ComponentLookupException e) {
            return addExceptionToResult(result, e);
        }

        eventCatapult.fire(ExecutionEvent.Type.ProjectDiscoveryStarted, session, null);

        Result<? extends ProjectDependencyGraph> graphResult = buildGraph(session);

        if (graphResult.hasErrors()) {
            return addExceptionToResult(
                    result, graphResult.getProblems().iterator().next().getException());
        }

        try {
            session.setProjectMap(getProjectMap(session.getProjects()));
        } catch (DuplicateProjectException e) {
            return addExceptionToResult(result, e);
        }

        try {
            setupWorkspaceReader(session, chainedWorkspaceReader);
        } catch (ComponentLookupException e) {
            return addExceptionToResult(result, e);
        }
        try {
            afterProjectsRead(session);
        } catch (MavenExecutionException e) {
            return addExceptionToResult(result, e);
        }

        //
        // The projects need to be topologically after the participants have run their afterProjectsRead(session)
        // because the participant is free to change the dependencies of a project which can potentially change the
        // topological order of the projects, and therefore can potentially change the build order.
        //
        // Note that participants may affect the topological order of the projects but it is
        // not expected that a participant will add or remove projects from the session.
        //

        graphResult = buildGraph(session);

        if (graphResult.hasErrors()) {
            return addExceptionToResult(
                    result, graphResult.getProblems().iterator().next().getException());
        }

        try {
            if (result.hasExceptions()) {
                return result;
            }

            result.setTopologicallySortedProjects(session.getProjects());

            result.setProject(session.getTopLevelProject());

            validatePrerequisitesForNonMavenPluginProjects(session.getProjects());

            validateRequiredProfiles(session, request.getProfileActivation());
            if (session.getResult().hasExceptions()) {
                return result;
            }

            validateOptionalProfiles(session, request.getProfileActivation());

            lifecycleStarter.execute(session);

            validateOptionalProjects(request, session);
            validateOptionalProfiles(session, request.getProfileActivation());

            if (session.getResult().hasExceptions()) {
                addExceptionToResult(result, session.getResult().getExceptions().get(0));
                persistResumptionData(result, session);
                return result;
            } else {
                session.getAllProjects().stream()
                        .filter(MavenProject::isExecutionRoot)
                        .findFirst()
                        .ifPresent(buildResumptionDataRepository::removeResumptionData);
            }
        } finally {
            try {
                afterSessionEnd(session);
            } catch (MavenExecutionException e) {
                return addExceptionToResult(result, e);
            }
        }

        return result;
    }

    private void setupWorkspaceReader(MavenSession session, MavenChainedWorkspaceReader chainedWorkspaceReader)
            throws ComponentLookupException {
        // Desired order of precedence for workspace readers before querying the local artifact repositories
        Set<WorkspaceReader> workspaceReaders = new LinkedHashSet<>();
        // 1) Reactor workspace reader
        WorkspaceReader reactorReader = container.lookup(WorkspaceReader.class, ReactorReader.HINT);
        workspaceReaders.add(reactorReader);
        // 2) Repository system session-scoped workspace reader
        for (WorkspaceReader repoWorkspaceReader : chainedWorkspaceReader.getReaders()) {
            if (repoWorkspaceReader != null && repoWorkspaceReader != reactorReader) {
                workspaceReaders.add(repoWorkspaceReader);
            }
        }
        // 3) .. n) Project-scoped workspace readers
        workspaceReaders.addAll(getProjectScopedExtensionComponents(session.getProjects(), WorkspaceReader.class));
        chainedWorkspaceReader.setReaders(workspaceReaders);
    }

    private void afterSessionStart(MavenSession session) throws MavenExecutionException {
        callListeners(session, AbstractMavenLifecycleParticipant::afterSessionStart);
    }

    private void afterProjectsRead(MavenSession session) throws MavenExecutionException {
        callListeners(session, AbstractMavenLifecycleParticipant::afterProjectsRead);
    }

    private void afterSessionEnd(MavenSession session) throws MavenExecutionException {
        callListeners(session, AbstractMavenLifecycleParticipant::afterSessionEnd);
    }

    @FunctionalInterface
    interface ListenerMethod {
        void run(AbstractMavenLifecycleParticipant listener, MavenSession session) throws MavenExecutionException;
    }

    private void callListeners(MavenSession session, ListenerMethod method) throws MavenExecutionException {
        ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
        try {
            for (AbstractMavenLifecycleParticipant listener :
                    getExtensionComponents(session.getProjects(), AbstractMavenLifecycleParticipant.class)) {
                Thread.currentThread().setContextClassLoader(listener.getClass().getClassLoader());
                method.run(listener, session);
            }
        } finally {
            Thread.currentThread().setContextClassLoader(originalClassLoader);
        }
    }

    private void persistResumptionData(MavenExecutionResult result, MavenSession session) {
        boolean hasLifecycleExecutionExceptions =
                result.getExceptions().stream().anyMatch(LifecycleExecutionException.class::isInstance);

        if (hasLifecycleExecutionExceptions) {
            MavenProject rootProject = session.getAllProjects().stream()
                    .filter(MavenProject::isExecutionRoot)
                    .findFirst()
                    .orElseThrow(() -> new IllegalStateException("No project in the session is execution root"));

            buildResumptionAnalyzer.determineBuildResumptionData(result).ifPresent(resumption -> {
                try {
                    buildResumptionDataRepository.persistResumptionData(rootProject, resumption);
                    result.setCanResume(true);
                } catch (BuildResumptionPersistenceException e) {
                    logger.warn("Could not persist build resumption data", e);
                }
            });
        }
    }

    /**
     * Nobody should ever use this method.
     *
     * @deprecated If you use this method and your code is not in Maven Core, stop doing this.
     */
    @Deprecated
    public RepositorySystemSession newRepositorySession(MavenExecutionRequest request) {
        return newCloseableSession(request, new MavenChainedWorkspaceReader());
    }

    private CloseableSession newCloseableSession(MavenExecutionRequest request, WorkspaceReader workspaceReader) {
        return repositorySessionFactory
                .newRepositorySessionBuilder(request)
                .setWorkspaceReader(workspaceReader)
                .build();
    }

    private void validateLocalRepository(MavenExecutionRequest request) throws IOException {
        File localRepoDir = request.getLocalRepositoryPath();

        logger.debug("Using local repository at {}", localRepoDir);

        localRepoDir.mkdirs();

        if (!localRepoDir.isDirectory()) {
            throw new IOException("Could not create local repository at " + localRepoDir);
        }
    }

    private <T> Collection<T> getExtensionComponents(Collection<MavenProject> projects, Class<T> role) {
        Collection<T> foundComponents = new LinkedHashSet<>();

        try {
            foundComponents.addAll(container.lookupList(role));
        } catch (ComponentLookupException e) {
            // this is just silly, lookupList should return an empty list!
            logger.warn("Failed to lookup {}: {}", role, e.getMessage());
        }

        foundComponents.addAll(getProjectScopedExtensionComponents(projects, role));

        return foundComponents;
    }

    protected <T> Collection<T> getProjectScopedExtensionComponents(Collection<MavenProject> projects, Class<T> role) {
        if (projects == null) {
            return Collections.emptyList();
        }

        Collection<T> foundComponents = new LinkedHashSet<>();
        Collection<ClassLoader> scannedRealms = new HashSet<>();

        Thread currentThread = Thread.currentThread();
        ClassLoader originalContextClassLoader = currentThread.getContextClassLoader();
        try {
            for (MavenProject project : projects) {
                ClassLoader projectRealm = project.getClassRealm();

                if (projectRealm != null && scannedRealms.add(projectRealm)) {
                    currentThread.setContextClassLoader(projectRealm);

                    try {
                        foundComponents.addAll(container.lookupList(role));
                    } catch (ComponentLookupException e) {
                        // this is just silly, lookupList should return an empty list!
                        logger.warn("Failed to lookup {}: {}", role, e.getMessage());
                    }
                }
            }
            return foundComponents;
        } finally {
            currentThread.setContextClassLoader(originalContextClassLoader);
        }
    }

    private MavenExecutionResult addExceptionToResult(MavenExecutionResult result, Throwable e) {
        if (!result.getExceptions().contains(e)) {
            result.addException(e);
        }

        return result;
    }

    private void validatePrerequisitesForNonMavenPluginProjects(List<MavenProject> projects) {
        for (MavenProject mavenProject : projects) {
            if (!"maven-plugin".equals(mavenProject.getPackaging())) {
                Prerequisites prerequisites =
                        mavenProject.getModel().getDelegate().getPrerequisites();
                if (prerequisites != null && prerequisites.getMaven() != null) {
                    logger.warn(
                            "The project {} uses prerequisites"
                                    + " which is only intended for maven-plugin projects "
                                    + "but not for non maven-plugin projects. "
                                    + "For such purposes you should use the maven-enforcer-plugin. "
                                    + "See https://maven.apache.org/enforcer/enforcer-rules/requireMavenVersion.html",
                            mavenProject.getId());
                }
            }
        }
    }

    /**
     * Get all profiles that are detected in the projects, any parent of the projects, or the settings.
     * @param session The Maven session
     * @return A {@link Set} of profile identifiers, never {@code null}.
     */
    private Set<String> getAllProfiles(MavenSession session) {
        final Map<String, Model> superPomModels = new HashMap<>();
        final Set<MavenProject> projectsIncludingParents = new HashSet<>();
        for (MavenProject project : session.getProjects()) {
            superPomModels.computeIfAbsent(
                    project.getModelVersion(),
                    v -> superPomProvider.getSuperModel(v).getDelegate());
            boolean isAdded = projectsIncludingParents.add(project);
            MavenProject parent = project.getParent();
            while (isAdded && parent != null) {
                isAdded = projectsIncludingParents.add(parent);
                parent = parent.getParent();
            }
        }

        final Stream<String> projectProfiles = projectsIncludingParents.stream()
                .flatMap(p -> p.getModel().getDelegate().getProfiles().stream())
                .map(Profile::getId);
        final Stream<String> settingsProfiles =
                session.getSettings().getProfiles().stream().map(org.apache.maven.settings.Profile::getId);
        final Stream<String> superPomProfiles = superPomModels.values().stream()
                .flatMap(p -> p.getProfiles().stream())
                .map(Profile::getId);

        return Stream.of(projectProfiles, settingsProfiles, superPomProfiles)
                .flatMap(Function.identity())
                .collect(toSet());
    }

    /**
     * Check whether the required profiles were found in any of the projects we're building or the settings.
     * @param session the Maven session.
     * @param profileActivation the requested optional and required profiles.
     */
    private void validateRequiredProfiles(MavenSession session, ProfileActivation profileActivation) {
        final Set<String> allAvailableProfiles = getAllProfiles(session);

        final Set<String> requiredProfiles = new HashSet<>();
        requiredProfiles.addAll(profileActivation.getRequiredActiveProfileIds());
        requiredProfiles.addAll(profileActivation.getRequiredInactiveProfileIds());

        // Check whether the required profiles were found in any of the projects we're building.
        final Set<String> notFoundRequiredProfiles = requiredProfiles.stream()
                .filter(rap -> !allAvailableProfiles.contains(rap))
                .collect(toSet());

        if (!notFoundRequiredProfiles.isEmpty()) {
            // Use SLF4J formatter for consistency with warnings reported by logger
            final String message = MessageFormatter.format(
                            "The requested profiles {} could not be activated or deactivated because they do not"
                                    + " exist.",
                            notFoundRequiredProfiles)
                    .getMessage();
            addExceptionToResult(session.getResult(), new MissingProfilesException(message));
        }
    }

    /**
     * Check whether any of the requested optional projects were not activated or deactivated.
     * @param request the {@link MavenExecutionRequest}.
     * @param session the {@link MavenSession}.
     */
    private void validateOptionalProjects(MavenExecutionRequest request, MavenSession session) {
        final ProjectActivation projectActivation = request.getProjectActivation();
        final Set<String> allOptionalSelectors = new HashSet<>();
        allOptionalSelectors.addAll(projectActivation.getOptionalActiveProjectSelectors());
        allOptionalSelectors.addAll(projectActivation.getRequiredActiveProjectSelectors());
        // We intentionally ignore the results of this method.
        // As a side effect it will log the optional projects that could not be resolved.
        projectSelector.getOptionalProjectsBySelectors(request, session.getAllProjects(), allOptionalSelectors);
    }

    /**
     * Check whether any of the requested optional profiles were not activated or deactivated.
     * @param session the Maven session.
     * @param profileActivation the requested optional and required profiles.
     */
    private void validateOptionalProfiles(MavenSession session, ProfileActivation profileActivation) {
        final Set<String> allAvailableProfiles = getAllProfiles(session);

        final Set<String> optionalProfiles = new HashSet<>();
        optionalProfiles.addAll(profileActivation.getOptionalActiveProfileIds());
        optionalProfiles.addAll(profileActivation.getOptionalInactiveProfileIds());

        final Set<String> notFoundOptionalProfiles = optionalProfiles.stream()
                .filter(rap -> !allAvailableProfiles.contains(rap))
                .collect(toSet());

        if (!notFoundOptionalProfiles.isEmpty()) {
            logger.info(
                    "The requested optional profiles {} could not be activated or deactivated because they do not"
                            + " exist.",
                    notFoundOptionalProfiles);
        }
    }

    private Map<String, MavenProject> getProjectMap(Collection<MavenProject> projects)
            throws DuplicateProjectException {
        Map<String, MavenProject> index = new LinkedHashMap<>();
        Map<String, List<File>> collisions = new LinkedHashMap<>();

        for (MavenProject project : projects) {
            String projectId = ArtifactUtils.key(project.getGroupId(), project.getArtifactId(), project.getVersion());

            MavenProject collision = index.get(projectId);

            if (collision == null) {
                index.put(projectId, project);
            } else {
                List<File> pomFiles = collisions.get(projectId);

                if (pomFiles == null) {
                    pomFiles = new ArrayList<>(Arrays.asList(collision.getFile(), project.getFile()));
                    collisions.put(projectId, pomFiles);
                } else {
                    pomFiles.add(project.getFile());
                }
            }
        }

        if (!collisions.isEmpty()) {
            throw new DuplicateProjectException(
                    "Two or more projects in the reactor"
                            + " have the same identifier, please make sure that <groupId>:<artifactId>:<version>"
                            + " is unique for each project: " + collisions,
                    collisions);
        }

        return index;
    }

    private Result<? extends ProjectDependencyGraph> buildGraph(MavenSession session) {
        Result<? extends ProjectDependencyGraph> graphResult = graphBuilder.build(session);
        for (ModelProblem problem : graphResult.getProblems()) {
            if (problem.getSeverity() == ModelProblem.Severity.WARNING) {
                logger.warn(problem.getMessage());
            } else {
                logger.error(problem.getMessage());
            }
        }

        if (!graphResult.hasErrors()) {
            ProjectDependencyGraph projectDependencyGraph = graphResult.get();
            session.setProjects(projectDependencyGraph.getSortedProjects());
            session.setAllProjects(projectDependencyGraph.getAllProjects());
            session.setProjectDependencyGraph(projectDependencyGraph);
        }

        return graphResult;
    }

    @Deprecated
    // 5 January 2014
    protected Logger getLogger() {
        return logger;
    }
}
