/*
 * 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.shared.release;

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

import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.commons.lang3.BooleanUtils;
import org.apache.maven.shared.release.config.ReleaseDescriptor;
import org.apache.maven.shared.release.config.ReleaseDescriptorBuilder;
import org.apache.maven.shared.release.config.ReleaseDescriptorBuilder.BuilderReleaseDescriptor;
import org.apache.maven.shared.release.config.ReleaseDescriptorStore;
import org.apache.maven.shared.release.config.ReleaseDescriptorStoreException;
import org.apache.maven.shared.release.config.ReleaseUtils;
import org.apache.maven.shared.release.phase.ReleasePhase;
import org.apache.maven.shared.release.phase.ResourceGenerator;
import org.apache.maven.shared.release.strategy.Strategy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static java.util.Objects.requireNonNull;

/**
 * Implementation of the release manager.
 *
 * @author <a href="mailto:brett@apache.org">Brett Porter</a>
 */
@Singleton
@Named
public class DefaultReleaseManager implements ReleaseManager {
    private final Logger logger = LoggerFactory.getLogger(getClass());

    private final Map<String, Strategy> strategies;

    /**
     * The available phases.
     */
    private final Map<String, ReleasePhase> releasePhases;

    /**
     * The configuration storage.
     */
    private final AtomicReference<ReleaseDescriptorStore> configStore;

    @Inject
    public DefaultReleaseManager(
            Map<String, Strategy> strategies,
            Map<String, ReleasePhase> releasePhases,
            @Named("properties") ReleaseDescriptorStore configStore) {
        this.strategies = requireNonNull(strategies);
        this.releasePhases = requireNonNull(releasePhases);
        this.configStore = new AtomicReference<>(requireNonNull(configStore));
    }

    /**
     * For easier testing only!
     */
    public void setConfigStore(ReleaseDescriptorStore configStore) {
        this.configStore.set(configStore);
    }

    @Override
    public ReleaseResult prepareWithResult(ReleasePrepareRequest prepareRequest) {
        ReleaseResult result = new ReleaseResult();

        result.setStartTime(System.currentTimeMillis());

        try {
            prepare(prepareRequest, result);

            result.setResultCode(ReleaseResult.SUCCESS);
        } catch (ReleaseExecutionException | ReleaseFailureException e) {
            captureException(result, prepareRequest.getReleaseManagerListener(), e);
        } finally {
            result.setEndTime(System.currentTimeMillis());
        }

        return result;
    }

    @Override
    public void prepare(ReleasePrepareRequest prepareRequest)
            throws ReleaseExecutionException, ReleaseFailureException {
        prepare(prepareRequest, new ReleaseResult());
    }

    private void prepare(ReleasePrepareRequest prepareRequest, ReleaseResult result)
            throws ReleaseExecutionException, ReleaseFailureException {

        final ReleaseDescriptorBuilder builder = prepareRequest.getReleaseDescriptorBuilder();

        // Create a config containing values from the session properties (ie command line properties with cli).
        ReleaseUtils.copyPropertiesToReleaseDescriptor(
                prepareRequest.getUserProperties(), new ReleaseDescriptorBuilder() {
                    public ReleaseDescriptorBuilder addDevelopmentVersion(String key, String value) {
                        builder.addDevelopmentVersion(key, value);
                        return this;
                    }

                    public ReleaseDescriptorBuilder addReleaseVersion(String key, String value) {
                        builder.addReleaseVersion(key, value);
                        return this;
                    }

                    public ReleaseDescriptorBuilder addDependencyReleaseVersion(String dependencyKey, String version) {
                        builder.addDependencyReleaseVersion(dependencyKey, version);
                        return this;
                    }

                    public ReleaseDescriptorBuilder addDependencyDevelopmentVersion(
                            String dependencyKey, String version) {
                        builder.addDependencyDevelopmentVersion(dependencyKey, version);
                        return this;
                    }
                });

        BuilderReleaseDescriptor config;
        if (BooleanUtils.isNotFalse(prepareRequest.getResume())) {
            config = loadReleaseDescriptor(builder, prepareRequest.getReleaseManagerListener());
        } else {
            config = ReleaseUtils.buildReleaseDescriptor(builder);
        }

        Strategy releaseStrategy = getStrategy(config.getReleaseStrategyId());

        List<String> preparePhases = getGoalPhases(releaseStrategy, "prepare");

        goalStart(prepareRequest.getReleaseManagerListener(), "prepare", preparePhases);

        // Later, it would be a good idea to introduce a proper workflow tool so that the release can be made up of a
        // more flexible set of steps.

        String completedPhase = config.getCompletedPhase();
        int index = preparePhases.indexOf(completedPhase);

        for (int idx = 0; idx <= index; idx++) {
            phaseSkip(prepareRequest.getReleaseManagerListener(), preparePhases.get(idx));
        }

        if (index == preparePhases.size() - 1) {
            logInfo(
                    result,
                    "Release preparation already completed. You can now continue with release:perform, "
                            + "or start again using the -Dresume=false flag");
        } else if (index >= 0) {
            logInfo(result, "Resuming release from phase '" + preparePhases.get(index + 1) + "'");
        }

        // start from next phase
        for (int i = index + 1; i < preparePhases.size(); i++) {
            String name = preparePhases.get(i);

            ReleasePhase phase = releasePhases.get(name);

            if (phase == null) {
                throw new ReleaseExecutionException("Unable to find phase '" + name + "' to execute");
            }

            phaseStart(prepareRequest.getReleaseManagerListener(), name);

            ReleaseResult phaseResult = null;
            try {
                if (BooleanUtils.isTrue(prepareRequest.getDryRun())) {
                    phaseResult = phase.simulate(
                            config, prepareRequest.getReleaseEnvironment(), prepareRequest.getReactorProjects());
                } else {
                    phaseResult = phase.execute(
                            config, prepareRequest.getReleaseEnvironment(), prepareRequest.getReactorProjects());
                }
            } finally {
                if (result != null && phaseResult != null) {
                    result.appendOutput(phaseResult.getOutput());
                }
            }

            config.setCompletedPhase(name);
            try {
                configStore.get().write(config);
            } catch (ReleaseDescriptorStoreException e) {
                // TODO: rollback?
                throw new ReleaseExecutionException("Error writing release properties after completing phase", e);
            }

            phaseEnd(prepareRequest.getReleaseManagerListener());
        }

        goalEnd(prepareRequest.getReleaseManagerListener());
    }

    @Override
    public void rollback(ReleaseRollbackRequest rollbackRequest)
            throws ReleaseExecutionException, ReleaseFailureException {
        ReleaseDescriptor releaseDescriptor =
                loadReleaseDescriptor(rollbackRequest.getReleaseDescriptorBuilder(), null);

        Strategy releaseStrategy = getStrategy(releaseDescriptor.getReleaseStrategyId());

        List<String> rollbackPhases = getGoalPhases(releaseStrategy, "rollback");

        goalStart(rollbackRequest.getReleaseManagerListener(), "rollback", rollbackPhases);

        for (String name : rollbackPhases) {
            ReleasePhase phase = releasePhases.get(name);

            if (phase == null) {
                throw new ReleaseExecutionException("Unable to find phase '" + name + "' to execute");
            }

            phaseStart(rollbackRequest.getReleaseManagerListener(), name);
            phase.execute(
                    releaseDescriptor, rollbackRequest.getReleaseEnvironment(), rollbackRequest.getReactorProjects());
            phaseEnd(rollbackRequest.getReleaseManagerListener());
        }

        // call release:clean so that resume will not be possible anymore after a rollback
        clean(rollbackRequest);
        goalEnd(rollbackRequest.getReleaseManagerListener());
    }

    @Override
    public ReleaseResult performWithResult(ReleasePerformRequest performRequest) {
        ReleaseResult result = new ReleaseResult();

        try {
            result.setStartTime(System.currentTimeMillis());

            perform(performRequest, result);

            result.setResultCode(ReleaseResult.SUCCESS);
        } catch (ReleaseExecutionException | ReleaseFailureException e) {
            captureException(result, performRequest.getReleaseManagerListener(), e);
        } finally {
            result.setEndTime(System.currentTimeMillis());
        }

        return result;
    }

    @Override
    public void perform(ReleasePerformRequest performRequest)
            throws ReleaseExecutionException, ReleaseFailureException {
        perform(performRequest, new ReleaseResult());
    }

    private void perform(ReleasePerformRequest performRequest, ReleaseResult result)
            throws ReleaseExecutionException, ReleaseFailureException {

        // https://issues.apache.org/jira/browse/MRELEASE-1104 because stageRepository is an additional arg
        // and only adding at perform stage it's not available during prepare and so not save the not available
        // when reloading. save this then change again after load
        String additionalArguments =
                performRequest.getReleaseDescriptorBuilder().build().getAdditionalArguments();

        List<String> specificProfiles = ReleaseUtils.buildReleaseDescriptor(
                        performRequest.getReleaseDescriptorBuilder())
                .getActivateProfiles();

        ReleaseDescriptorBuilder builder = loadReleaseDescriptorBuilder(
                performRequest.getReleaseDescriptorBuilder(), performRequest.getReleaseManagerListener());

        builder.setAdditionalArguments(additionalArguments);

        if (specificProfiles != null && !specificProfiles.isEmpty()) {
            List<String> allProfiles =
                    new ArrayList<>(ReleaseUtils.buildReleaseDescriptor(builder).getActivateProfiles());
            for (String specificProfile : specificProfiles) {
                if (!allProfiles.contains(specificProfile)) {
                    allProfiles.add(specificProfile);
                }
            }
            builder.setActivateProfiles(allProfiles);
        }

        ReleaseDescriptor releaseDescriptor = ReleaseUtils.buildReleaseDescriptor(builder);

        Strategy releaseStrategy = getStrategy(releaseDescriptor.getReleaseStrategyId());

        List<String> performPhases = getGoalPhases(releaseStrategy, "perform");

        goalStart(performRequest.getReleaseManagerListener(), "perform", performPhases);

        for (String name : performPhases) {
            ReleasePhase phase = releasePhases.get(name);

            if (phase == null) {
                throw new ReleaseExecutionException("Unable to find phase '" + name + "' to execute");
            }

            phaseStart(performRequest.getReleaseManagerListener(), name);

            ReleaseResult phaseResult = null;
            try {
                if (BooleanUtils.isTrue(performRequest.getDryRun())) {
                    phaseResult = phase.simulate(
                            releaseDescriptor,
                            performRequest.getReleaseEnvironment(),
                            performRequest.getReactorProjects());
                } else {
                    phaseResult = phase.execute(
                            releaseDescriptor,
                            performRequest.getReleaseEnvironment(),
                            performRequest.getReactorProjects());
                }
            } finally {
                if (result != null && phaseResult != null) {
                    result.appendOutput(phaseResult.getOutput());
                }
            }

            phaseEnd(performRequest.getReleaseManagerListener());
        }

        if (BooleanUtils.isNotFalse(performRequest.getClean())) {
            // call release:clean so that resume will not be possible anymore after a perform
            clean(performRequest);
        }

        goalEnd(performRequest.getReleaseManagerListener());
    }

    @Override
    public void branch(ReleaseBranchRequest branchRequest) throws ReleaseExecutionException, ReleaseFailureException {
        final ReleaseDescriptorBuilder builder = branchRequest.getReleaseDescriptorBuilder();

        ReleaseUtils.copyPropertiesToReleaseDescriptor(
                branchRequest.getUserProperties(), new ReleaseDescriptorBuilder() {
                    public ReleaseDescriptorBuilder addDevelopmentVersion(String key, String value) {
                        builder.addDevelopmentVersion(key, value);
                        return this;
                    }

                    public ReleaseDescriptorBuilder addReleaseVersion(String key, String value) {
                        builder.addReleaseVersion(key, value);
                        return this;
                    }
                });

        ReleaseDescriptor releaseDescriptor = loadReleaseDescriptor(builder, branchRequest.getReleaseManagerListener());

        boolean dryRun = BooleanUtils.isTrue(branchRequest.getDryRun());

        Strategy releaseStrategy = getStrategy(releaseDescriptor.getReleaseStrategyId());

        List<String> branchPhases = getGoalPhases(releaseStrategy, "branch");

        goalStart(branchRequest.getReleaseManagerListener(), "branch", branchPhases);

        for (String name : branchPhases) {
            ReleasePhase phase = releasePhases.get(name);

            if (phase == null) {
                throw new ReleaseExecutionException("Unable to find phase '" + name + "' to execute");
            }

            phaseStart(branchRequest.getReleaseManagerListener(), name);

            if (dryRun) {
                phase.simulate(
                        releaseDescriptor, branchRequest.getReleaseEnvironment(), branchRequest.getReactorProjects());
            } else // getDryRun is null or FALSE
            {
                phase.execute(
                        releaseDescriptor, branchRequest.getReleaseEnvironment(), branchRequest.getReactorProjects());
            }

            phaseEnd(branchRequest.getReleaseManagerListener());
        }

        if (!dryRun) {
            clean(branchRequest);
        }

        goalEnd(branchRequest.getReleaseManagerListener());
    }

    @Override
    public void updateVersions(ReleaseUpdateVersionsRequest updateVersionsRequest)
            throws ReleaseExecutionException, ReleaseFailureException {
        final ReleaseDescriptorBuilder builder = updateVersionsRequest.getReleaseDescriptorBuilder();

        // Create a config containing values from the session properties (ie command line properties with cli).
        ReleaseUtils.copyPropertiesToReleaseDescriptor(
                updateVersionsRequest.getUserProperties(), new ReleaseDescriptorBuilder() {
                    public ReleaseDescriptorBuilder addDevelopmentVersion(String key, String value) {
                        builder.addDevelopmentVersion(key, value);
                        return this;
                    }

                    public ReleaseDescriptorBuilder addReleaseVersion(String key, String value) {
                        builder.addReleaseVersion(key, value);
                        return this;
                    }
                });

        ReleaseDescriptor releaseDescriptor =
                loadReleaseDescriptor(builder, updateVersionsRequest.getReleaseManagerListener());

        Strategy releaseStrategy = getStrategy(releaseDescriptor.getReleaseStrategyId());

        List<String> updateVersionsPhases = getGoalPhases(releaseStrategy, "updateVersions");

        goalStart(updateVersionsRequest.getReleaseManagerListener(), "updateVersions", updateVersionsPhases);

        for (String name : updateVersionsPhases) {
            ReleasePhase phase = releasePhases.get(name);

            if (phase == null) {
                throw new ReleaseExecutionException("Unable to find phase '" + name + "' to execute");
            }

            phaseStart(updateVersionsRequest.getReleaseManagerListener(), name);
            phase.execute(
                    releaseDescriptor,
                    updateVersionsRequest.getReleaseEnvironment(),
                    updateVersionsRequest.getReactorProjects());
            phaseEnd(updateVersionsRequest.getReleaseManagerListener());
        }

        clean(updateVersionsRequest);

        goalEnd(updateVersionsRequest.getReleaseManagerListener());
    }

    /**
     * Determines the path of the working directory. By default, this is the
     * checkout directory. For some SCMs, the project root directory is not the
     * checkout directory itself, but a SCM-specific subdirectory.
     *
     * @param checkoutDirectory            the checkout directory as java.io.File
     * @param relativePathProjectDirectory the relative path of the project directory within the checkout
     *                                     directory or ""
     * @return the working directory
     */
    protected File determineWorkingDirectory(File checkoutDirectory, String relativePathProjectDirectory) {
        if (relativePathProjectDirectory != null && !relativePathProjectDirectory.isEmpty()) {
            return new File(checkoutDirectory, relativePathProjectDirectory);
        } else {
            return checkoutDirectory;
        }
    }

    private BuilderReleaseDescriptor loadReleaseDescriptor(
            ReleaseDescriptorBuilder builder, ReleaseManagerListener listener) throws ReleaseExecutionException {
        return ReleaseUtils.buildReleaseDescriptor(loadReleaseDescriptorBuilder(builder, listener));
    }

    private ReleaseDescriptorBuilder loadReleaseDescriptorBuilder(
            ReleaseDescriptorBuilder builder, ReleaseManagerListener listener) throws ReleaseExecutionException {
        try {
            return configStore.get().read(builder);
        } catch (ReleaseDescriptorStoreException e) {
            throw new ReleaseExecutionException("Error reading stored configuration: " + e.getMessage(), e);
        }
    }

    /**
     * <p>clean.</p>
     *
     * @param releaseRequest a {@link org.apache.maven.shared.release.AbstractReleaseRequest} object
     * @throws org.apache.maven.shared.release.ReleaseFailureException if any
     */
    protected void clean(AbstractReleaseRequest releaseRequest) throws ReleaseFailureException {
        ReleaseCleanRequest cleanRequest = new ReleaseCleanRequest();
        cleanRequest.setReleaseDescriptorBuilder(releaseRequest.getReleaseDescriptorBuilder());
        cleanRequest.setReleaseManagerListener(releaseRequest.getReleaseManagerListener());
        cleanRequest.setReactorProjects(releaseRequest.getReactorProjects());

        clean(cleanRequest);
    }

    @Override
    public void clean(ReleaseCleanRequest cleanRequest) throws ReleaseFailureException {
        logger.info("Cleaning up after release...");

        ReleaseDescriptor releaseDescriptor =
                ReleaseUtils.buildReleaseDescriptor(cleanRequest.getReleaseDescriptorBuilder());

        configStore.get().delete(releaseDescriptor);

        Strategy releaseStrategy = getStrategy(releaseDescriptor.getReleaseStrategyId());

        Set<String> phases = new LinkedHashSet<>();
        phases.addAll(getGoalPhases(releaseStrategy, "prepare"));
        phases.addAll(getGoalPhases(releaseStrategy, "branch"));

        for (String name : phases) {
            ReleasePhase phase = releasePhases.get(name);

            if (phase instanceof ResourceGenerator) {
                ((ResourceGenerator) phase).clean(cleanRequest.getReactorProjects());
            }
        }
    }

    void goalStart(ReleaseManagerListener listener, String goal, List<String> phases) {
        if (listener != null) {
            listener.goalStart(goal, phases);
        }
    }

    void goalEnd(ReleaseManagerListener listener) {
        if (listener != null) {
            listener.goalEnd();
        }
    }

    void phaseSkip(ReleaseManagerListener listener, String name) {
        if (listener != null) {
            listener.phaseSkip(name);
        }
    }

    void phaseStart(ReleaseManagerListener listener, String name) {
        if (listener != null) {
            listener.phaseStart(name);
        }
    }

    void phaseEnd(ReleaseManagerListener listener) {
        if (listener != null) {
            listener.phaseEnd();
        }
    }

    void error(ReleaseManagerListener listener, String name) {
        if (listener != null) {
            listener.error(name);
        }
    }

    private Strategy getStrategy(String strategyId) throws ReleaseFailureException {
        Strategy strategy = strategies.get(strategyId);
        if (strategy == null) {
            throw new ReleaseFailureException("Unknown strategy: " + strategyId);
        }
        return strategy;
    }

    private List<String> getGoalPhases(Strategy strategy, String goal) {
        List<String> phases;

        if ("prepare".equals(goal)) {
            phases = strategy.getPreparePhases();
            if (phases == null) {
                phases = strategies.get("default").getPreparePhases();
            }
        } else if ("perform".equals(goal)) {
            phases = strategy.getPerformPhases();
            if (phases == null) {
                phases = strategies.get("default").getPerformPhases();
            }
        } else if ("rollback".equals(goal)) {
            phases = strategy.getRollbackPhases();
            if (phases == null) {
                phases = strategies.get("default").getRollbackPhases();
            }
        } else if ("branch".equals(goal)) {
            phases = strategy.getBranchPhases();
            if (phases == null) {
                phases = strategies.get("default").getBranchPhases();
            }
        } else if ("updateVersions".equals(goal)) {
            phases = strategy.getUpdateVersionsPhases();
            if (phases == null) {
                phases = strategies.get("default").getUpdateVersionsPhases();
            }
        } else {
            phases = null;
        }

        return Collections.unmodifiableList(phases); // TODO: NPE here in phases=null above!
    }

    private void logInfo(ReleaseResult result, String message) {
        if (result != null) {
            result.appendInfo(message);
        }

        logger.info(message);
    }

    private void captureException(ReleaseResult result, ReleaseManagerListener listener, Exception e) {
        if (listener != null) {
            listener.error(e.getMessage());
        }

        result.appendError(e);

        result.setResultCode(ReleaseResult.ERROR);
    }
}
