/*
 * 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.archetype.mojos;

import javax.inject.Inject;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.maven.archetype.ArchetypeGenerationRequest;
import org.apache.maven.archetype.ArchetypeGenerationResult;
import org.apache.maven.archetype.common.Constants;
import org.apache.maven.archetype.downloader.DownloadException;
import org.apache.maven.archetype.downloader.Downloader;
import org.apache.maven.archetype.exception.ArchetypeNotConfigured;
import org.apache.maven.archetype.generator.ArchetypeGenerator;
import org.apache.maven.archetype.ui.generation.ArchetypeGenerationConfigurator;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.apache.maven.settings.Settings;
import org.apache.maven.settings.io.xpp3.SettingsXpp3Writer;
import org.apache.maven.shared.invoker.DefaultInvocationRequest;
import org.apache.maven.shared.invoker.InvocationRequest;
import org.apache.maven.shared.invoker.InvocationResult;
import org.apache.maven.shared.invoker.Invoker;
import org.apache.maven.shared.invoker.MavenInvocationException;
import org.apache.maven.shared.scriptinterpreter.ScriptException;
import org.apache.maven.shared.scriptinterpreter.ScriptRunner;
import org.codehaus.plexus.util.FileUtils;
import org.codehaus.plexus.util.IOUtil;
import org.codehaus.plexus.util.InterpolationFilterReader;
import org.codehaus.plexus.util.StringUtils;
import org.codehaus.plexus.util.introspection.ReflectionValueExtractor;
import org.codehaus.plexus.util.xml.XmlStreamReader;
import org.codehaus.plexus.util.xml.XmlStreamWriter;

/**
 * <p>Execute the archetype integration tests, consisting in generating projects from the current archetype and optionally
 * comparing generated projects with reference copy.</p>
 *
 * <p>Each IT consists of a sub-directory in <code>src/test/resources/projects</code> containing:</p>
 *
 * <ul>
 * <li>a <code>goal.txt</code> file, containing a list of goals to run against the generated project (can be empty,
 * content ignored before maven-archetype-plugin 2.1),</li>
 * <li>an <code>archetype.properties</code> file, containing properties for project generation,</li>
 * <li>an optional <code>reference/</code> directory containing a reference copy of the expected project created from
 * the IT.</li>
 * </ul>
 * <p>To let the IT create a Maven module below some other Maven project (being generated from another archetype)
 * one can additionally specify an optional <code>archetype.pom.properties</code> file in the parent directory,
 * specifying the archetype's <code>groupId</code>, <code>artifactId</code> and <code>version</code> along with its
 * <code>archetype.properties</code> file, containing properties for project generation. Both files are leveraged
 * to create the parent project for this IT. Parent projects can be nested.</p>
 *
 * <p>An example structure for such an integration test looks like this</p>
 * <table>
 * <caption>integration test folder structure</caption>
 * <tr>
 * <th>File/Directory</th>
 * <th>Description</th>
 * </tr>
 * <tr>
 * <td><code>src/test/resources/projects/it1</code></td>
 * <td>Directory for integration test 1</td>
 * </tr>
 * <tr>
 * <td><code>src/test/resources/projects/it1/archetype.pom.properties</code></td>
 * <td>GAV for the archetype from which to generate the parent</td>
 * </tr>
 * <tr>
 * <td><code>src/test/resources/projects/it1/archetype.properties</code></td>
 * <td>All required properties for the archetype being specified by <code>archetype.pom.properties</code> on this level</td>
 * </tr>
 * <tr>
 * <td><code>src/test/resources/projects/it1/child</code></td>
 * <td>Directory for maven module within integration test 1 (this folder's name is not relevant)</td>
 * </tr>
 * <tr>
 * <td><code>src/test/resources/projects/it1/child/goal.txt</code></td>
 * <td>The file containing the list of goals to be executed against the generated project</td>
 * </tr>
 * <tr>
 * <td><code>src/test/resources/projects/it1/child/archetype.properties</code></td>
 * <td>All required properties for this project's archetype</td>
 * </tr>
 * </table>
 *
 * <p>Notice that it is expected to be run as part as of a build after the <code>package</code> phase and not directly as a
 * goal from CLI.</p>
 *
 * @author rafale
 */
@Mojo(name = "integration-test", requiresProject = true)
public class IntegrationTestMojo extends AbstractMojo {

    private ArchetypeGenerator archetypeGenerator;

    private Downloader downloader;

    private Invoker invoker;

    private ArchetypeGenerationConfigurator archetypeGenerationConfigurator;

    @Inject
    public IntegrationTestMojo(
            ArchetypeGenerator archetypeGenerator,
            Downloader downloader,
            Invoker invoker,
            ArchetypeGenerationConfigurator archetypeGenerationConfigurator) {
        this.archetypeGenerator = archetypeGenerator;
        this.downloader = downloader;
        this.invoker = invoker;
        this.archetypeGenerationConfigurator = archetypeGenerationConfigurator;
    }

    /**
     * The archetype project to execute the integration tests on.
     */
    @Parameter(defaultValue = "${project}", readonly = true, required = true)
    private MavenProject project;

    @Parameter(defaultValue = "${session}", readonly = true, required = true)
    private MavenSession session;

    /**
     * Skip the integration test.
     */
    @Parameter(property = "archetype.test.skip")
    private boolean skip = false;

    /**
     * Directory of test projects.
     *
     * @since 2.2
     */
    @Parameter(
            property = "archetype.test.projectsDirectory",
            defaultValue = "${project.build.testOutputDirectory}/projects",
            required = true)
    private File testProjectsDirectory;

    /**
     * Relative path of a cleanup/verification hook script to run after executing the build. This script may be written
     * with either BeanShell or Groovy. If the file extension is omitted (e.g. <code>verify</code>), the
     * plugin searches for the file by trying out the well-known extensions <code>.bsh</code> and <code>.groovy</code>.
     * If this script exists for a particular project but returns any non-null value different from <code>true</code> or
     * throws an exception, the corresponding build is flagged as a failure.
     *
     * @since 2.2
     */
    @Parameter(property = "archetype.test.verifyScript", defaultValue = "verify")
    private String postBuildHookScript;

    /**
     * Suppress logging to the <code>build.log</code> file.
     *
     * @since 2.2
     */
    @Parameter(property = "archetype.test.noLog", defaultValue = "false")
    private boolean noLog;

    /**
     * Flag used to determine whether the build logs should be output to the normal mojo log.
     *
     * @since 2.2
     */
    @Parameter(property = "archetype.test.streamLogs", defaultValue = "true")
    private boolean streamLogs;

    /**
     * The file encoding for the post-build script.
     *
     * @since 2.2
     */
    @Parameter(property = "encoding", defaultValue = "${project.build.sourceEncoding}")
    private String encoding;

    /**
     * The local repository to run maven instance.
     *
     * @since 2.2
     */
    @Parameter(
            property = "archetype.test.localRepositoryPath",
            defaultValue = "${settings.localRepository}",
            required = true)
    private File localRepositoryPath;

    /**
     * Flag to enable show mvn version used for running its (cli option : -V,--show-version )
     *
     * @since 2.2
     */
    @Parameter(property = "archetype.test.showVersion", defaultValue = "false")
    private boolean showVersion;

    /**
     * Ignores the EOL encoding for comparing files (default and original behaviour is false).
     *
     * @since 2.3
     */
    @Parameter(property = "archetype.test.ignoreEOLStyle", defaultValue = "false")
    private boolean ignoreEOLStyle;

    /**
     * Whether to show debug statements in the build output.
     *
     * @since 2.2
     */
    @Parameter(property = "archetype.test.debug", defaultValue = "false")
    private boolean debug;

    /**
     * A list of additional properties which will be used to filter tokens in settings.xml.
     *
     * @since 2.2
     */
    @Parameter
    private Map<String, String> filterProperties;

    /**
     * The current user system settings for use in Maven.
     *
     * @since 2.2
     */
    @Parameter(defaultValue = "${settings}", required = true, readonly = true)
    private Settings settings;

    /**
     * Path to an alternate <code>settings.xml</code> to use for Maven invocation with all ITs. Note that the
     * <code>&lt;localRepository&gt;</code> element of this settings file is always ignored, i.e. the path given by the
     * parameter {@link #localRepositoryPath} is dominant.
     *
     * @since 2.2
     */
    @Parameter(property = "archetype.test.settingsFile")
    private File settingsFile;

    /**
     * Common set of properties to pass in on each project's command line, via -D parameters.
     *
     * @since 3.0.2
     */
    @Parameter
    private Map<String, String> properties = new HashMap<>();

    @Override
    public void execute() throws MojoExecutionException {
        if (skip) {
            return;
        }

        if (!testProjectsDirectory.exists()) {
            getLog().warn("No Archetype IT projects: root 'projects' directory not found.");

            return;
        }

        File archetypeFile = project.getArtifact().getFile();

        if (archetypeFile == null) {
            throw new MojoExecutionException("Unable to get the archetypes' artifact which should have just been built:"
                    + " you probably launched 'mvn archetype:integration-test' instead of"
                    + " 'mvn integration-test'.");
        }

        try {
            List<File> projectsGoalFiles = FileUtils.getFiles(testProjectsDirectory, "**/goal.txt", "");

            if (projectsGoalFiles.isEmpty()) {
                getLog().warn("No Archetype IT projects: no directory with goal.txt found.");

                return;
            }

            StringWriter errorWriter = new StringWriter();
            for (File goalFile : projectsGoalFiles) {
                try {
                    processIntegrationTest(goalFile, archetypeFile);
                } catch (IntegrationTestFailure ex) {
                    errorWriter.write(
                            "\nArchetype IT '" + goalFile.getParentFile().getName() + "' failed: ");
                    errorWriter.write(ex.getMessage());
                }
            }

            String errors = errorWriter.toString();
            if (!(errors == null || errors.isEmpty())) {
                throw new MojoExecutionException(errors);
            }
        } catch (IOException ex) {
            throw new MojoExecutionException(ex.getMessage(), ex);
        }
    }

    /**
     * Checks that actual directory content is the same as reference.
     *
     * @param reference the reference directory
     * @param actual    the actual directory to compare with the reference
     * @throws IntegrationTestFailure if content differs
     */
    private void assertDirectoryEquals(File reference, File actual) throws IntegrationTestFailure, IOException {
        List<String> referenceFiles =
                FileUtils.getFileAndDirectoryNames(reference, "**", null, false, true, true, true);
        getLog().debug("reference content: " + referenceFiles);

        List<String> actualFiles = FileUtils.getFileAndDirectoryNames(actual, "**", null, false, true, true, true);
        getLog().debug("actual content: " + actualFiles);

        boolean fileNamesEquals = CollectionUtils.isEqualCollection(referenceFiles, actualFiles);

        if (!fileNamesEquals) {
            getLog().debug("Actual list of files is not the same as reference:");
            int missing = 0;
            for (String ref : referenceFiles) {
                if (actualFiles.contains(ref)) {
                    actualFiles.remove(ref);
                    getLog().debug("Contained " + ref);
                } else {
                    missing++;
                    getLog().error("Not contained " + ref);
                }
            }
            getLog().error("Remains " + actualFiles);

            throw new IntegrationTestFailure("Reference and generated project differs (missing: " + missing
                    + ", unexpected: " + actualFiles.size() + ")");
        }

        if (!ignoreEOLStyle) {
            getLog().warn("Property ignoreEOLStyle was not set - files will be compared considering their EOL style!");
        }

        boolean contentEquals = true;

        for (String file : referenceFiles) {
            File referenceFile = new File(reference, file);
            File actualFile = new File(actual, file);

            if (referenceFile.isDirectory()) {
                if (actualFile.isFile()) {
                    getLog().error("File " + file + " is a directory in the reference but a file in actual");
                    contentEquals = false;
                }
            } else if (actualFile.isDirectory()) {
                if (referenceFile.isFile()) {
                    getLog().error("File " + file + " is a file in the reference but a directory in actual");
                    contentEquals = false;
                }
            } else if (!contentEquals(referenceFile, actualFile)) {
                getLog().error("Contents of file " + file + " are not equal");
                contentEquals = false;
            }
        }
        if (!contentEquals) {
            throw new IntegrationTestFailure("Some content are not equals");
        }
    }

    /**
     * Uses the {@link #ignoreEOLStyle} attribute to compare the two files. If {@link #ignoreEOLStyle} is true,
     * then the comparison does not take care about the EOL (aka newline) character.
     */
    private boolean contentEquals(File referenceFile, File actualFile) throws IOException {
        // Original behaviour
        if (!ignoreEOLStyle) {
            return FileUtils.contentEquals(referenceFile, actualFile);
        }

        getLog().debug("Comparing files with EOL style ignored.");

        try (BufferedReader referenceFileReader = new BufferedReader(new FileReader(referenceFile));
                BufferedReader actualFileReader = new BufferedReader(new FileReader(actualFile))) {
            String refLine = null;
            String actualLine = null;

            do {
                refLine = referenceFileReader.readLine();
                actualLine = actualFileReader.readLine();
                if (!Objects.equals(refLine, actualLine)) {
                    getLog().error("Conflict found. Reference line :");
                    getLog().error(refLine);
                    getLog().error("Actual line :");
                    getLog().error(actualLine);
                    return false;
                }
            } while (refLine != null || actualLine != null);

            return true;
        }
    }

    private Properties loadProperties(final File propertiesFile) throws IOException {
        Properties properties = new Properties();

        try (InputStream in = Files.newInputStream(propertiesFile.toPath())) {
            properties.load(in);
        }

        return properties;
    }

    private void processIntegrationTest(File goalFile, File archetypeFile)
            throws IntegrationTestFailure, MojoExecutionException {
        getLog().info("Processing Archetype IT project: "
                + goalFile.getParentFile().getName());

        try {
            Properties properties = getProperties(goalFile);

            File basedir = new File(goalFile.getParentFile(), "project");

            org.apache.commons.io.FileUtils.deleteDirectory(basedir);

            FileUtils.mkdir(basedir.toString());

            basedir = setupParentProjects(goalFile.getParentFile().getParentFile(), basedir);

            ArchetypeGenerationRequest request = generate(
                    project.getGroupId(),
                    project.getArtifactId(),
                    project.getVersion(),
                    archetypeFile,
                    properties,
                    basedir.toString());

            File reference = new File(goalFile.getParentFile(), "reference");

            if (reference.exists()) {
                // compare generated project with reference
                getLog().info("Comparing generated project with reference content: " + reference);

                assertDirectoryEquals(reference, new File(basedir, request.getArtifactId()));
            }

            String goals = FileUtils.fileRead(goalFile);

            if (goals != null && !goals.isEmpty()) {
                invokePostArchetypeGenerationGoals(goals.trim(), new File(basedir, request.getArtifactId()), goalFile);
            }
        } catch (IOException ioe) {
            throw new IntegrationTestFailure(ioe);
        }
    }

    private ArchetypeGenerationRequest generate(
            String archetypeGroupId,
            String archetypeArtifactId,
            String archetypeVersion,
            File archetypeFile,
            Properties properties,
            String basedir)
            throws IntegrationTestFailure, MojoExecutionException {
        // @formatter:off
        ArchetypeGenerationRequest request = new ArchetypeGenerationRequest()
                .setArchetypeGroupId(archetypeGroupId)
                .setArchetypeArtifactId(archetypeArtifactId)
                .setArchetypeVersion(archetypeVersion)
                .setGroupId(properties.getProperty(Constants.GROUP_ID))
                .setArtifactId(properties.getProperty(Constants.ARTIFACT_ID))
                .setVersion(properties.getProperty(Constants.VERSION))
                .setPackage(properties.getProperty(Constants.PACKAGE))
                .setRepositorySession(session.getRepositorySession())
                .setOutputDirectory(basedir)
                .setProperties(properties);
        // @formatter:on

        ArchetypeGenerationResult result = new ArchetypeGenerationResult();
        try {
            archetypeGenerationConfigurator.configureArchetype(request, false, properties);
        } catch (Exception e) {
            throw new MojoExecutionException("Cannot configure archetype", e);
        }
        archetypeGenerator.generateArchetype(request, archetypeFile, result);

        if (result.getCause() != null) {
            if (result.getCause() instanceof ArchetypeNotConfigured) {
                ArchetypeNotConfigured anc = (ArchetypeNotConfigured) result.getCause();

                throw new IntegrationTestFailure(
                        "Missing required properties in archetype.properties: "
                                + StringUtils.join(anc.getMissingProperties().iterator(), ", "),
                        anc);
            }

            throw new IntegrationTestFailure(result.getCause().getMessage(), result.getCause());
        }
        return request;
    }

    private File setupParentProjects(File configFolder, File buildFolder)
            throws IOException, MojoExecutionException, IntegrationTestFailure {
        // look for 'archetype.pom.properties'
        File archetypePomPropertiesFile = new File(configFolder, "archetype.pom.properties");
        if (!archetypePomPropertiesFile.exists()) {
            getLog().debug("No 'archetype.pom.properties' file found in " + configFolder);
            return buildFolder;
        }

        // go up to the parent configuration folder
        buildFolder = setupParentProjects(configFolder.getParentFile(), buildFolder);

        Properties archetypePomProperties = loadProperties(archetypePomPropertiesFile);
        String groupId = archetypePomProperties.getProperty(Constants.GROUP_ID);
        if (groupId == null || groupId.isEmpty()) {
            throw new MojoExecutionException(
                    "Property " + Constants.GROUP_ID + " not set in " + archetypePomPropertiesFile);
        }
        String artifactId = archetypePomProperties.getProperty(Constants.ARTIFACT_ID);
        if (artifactId == null || artifactId.isEmpty()) {
            throw new MojoExecutionException(
                    "Property " + Constants.ARTIFACT_ID + " not set in " + archetypePomPropertiesFile);
        }
        String version = archetypePomProperties.getProperty(Constants.VERSION);
        if (version == null || version.isEmpty()) {
            throw new MojoExecutionException(
                    "Property " + Constants.VERSION + " not set in " + archetypePomPropertiesFile);
        }

        File archetypeFile;
        try {
            archetypeFile = getArchetypeFile(groupId, artifactId, version);
        } catch (DownloadException e) {
            throw new MojoExecutionException("Could not resolve archetype artifact ", e);
        }
        Properties archetypeProperties = getProperties(archetypePomPropertiesFile);
        getLog().info("Setting up parent project in " + buildFolder);
        ArchetypeGenerationRequest request =
                generate(groupId, artifactId, version, archetypeFile, archetypeProperties, buildFolder.toString());
        return new File(buildFolder, request.getArtifactId());
    }

    private File getArchetypeFile(String groupId, String artifactId, String version) throws DownloadException {
        return downloader.download(
                groupId, artifactId, version, project.getRemoteProjectRepositories(), session.getRepositorySession());
    }

    private Properties getProperties(File goalFile) throws IOException {
        File propertiesFile = new File(goalFile.getParentFile(), "archetype.properties");

        return loadProperties(propertiesFile);
    }

    private void invokePostArchetypeGenerationGoals(String goals, File basedir, File goalFile)
            throws IntegrationTestFailure, IOException, MojoExecutionException {
        FileLogger logger = setupLogger(basedir);

        if (!StringUtils.isBlank(goals)) {

            getLog().info("Invoking post-archetype-generation goals: " + goals);

            if (!localRepositoryPath.exists()) {
                localRepositoryPath.mkdirs();
            }

            // @formatter:off
            InvocationRequest request = new DefaultInvocationRequest()
                    .setBaseDirectory(basedir)
                    .setGoals(Arrays.asList(StringUtils.split(goals, ",")))
                    .setLocalRepositoryDirectory(localRepositoryPath)
                    .setBatchMode(true)
                    .setShowErrors(true);
            // @formatter:on

            request.setDebug(debug);

            request.setShowVersion(showVersion);

            if (logger != null) {
                request.setErrorHandler(logger);
                request.setOutputHandler(logger);
            }

            if (!properties.isEmpty()) {
                Properties props = new Properties();
                for (Map.Entry<String, String> entry : properties.entrySet()) {
                    if (entry.getValue() != null) {
                        props.setProperty(entry.getKey(), entry.getValue());
                    }
                }
                request.setProperties(props);
            }

            File archetypeItDirectory = new File(project.getBuild().getDirectory(), "archetype-it");
            if (archetypeItDirectory.exists()) {
                org.apache.commons.io.FileUtils.deleteDirectory(archetypeItDirectory);
            }
            archetypeItDirectory.mkdir();
            File userSettings;
            if (settingsFile != null) {
                userSettings = new File(archetypeItDirectory, "interpolated-" + settingsFile.getName());

                buildInterpolatedFile(settingsFile, userSettings);
            } else {
                // Use settings coming from the main Maven build
                userSettings = new File(archetypeItDirectory, "archetype-settings.xml");

                SettingsXpp3Writer settingsWriter = new SettingsXpp3Writer();

                try (FileWriter fileWriter = new FileWriter(userSettings)) {
                    settingsWriter.write(fileWriter, settings);
                }
            }
            request.setUserSettingsFile(userSettings);

            try {
                InvocationResult result = invoker.execute(request);

                getLog().info("Post-archetype-generation invoker exit code: " + result.getExitCode());

                if (result.getExitCode() != 0) {
                    throw new IntegrationTestFailure(
                            "Execution failure: exit code = " + result.getExitCode(), result.getExecutionException());
                }
            } catch (MavenInvocationException e) {
                throw new IntegrationTestFailure("Cannot run additions goals.", e);
            }
        } else {
            getLog().info("No post-archetype-generation goals to invoke.");
        }
        // verify result
        try (ScriptRunner scriptRunner = new ScriptRunner()) {
            scriptRunner.setScriptEncoding(encoding);

            Map<String, Object> context = new LinkedHashMap<>();
            context.put("projectDir", basedir);

            scriptRunner.run("post-build script", goalFile.getParentFile(), postBuildHookScript, context, logger);
        } catch (ScriptException e) {
            throw new IntegrationTestFailure("post build script failure failure: " + e.getMessage(), e);
        }
    }

    private FileLogger setupLogger(File basedir) throws IOException {
        FileLogger logger = null;

        if (!noLog) {
            File outputLog = new File(basedir, "build.log");

            if (streamLogs) {
                logger = new FileLogger(outputLog, getLog());
            } else {
                logger = new FileLogger(outputLog);
            }

            getLog().debug("build log initialized in: " + outputLog);
        }

        return logger;
    }

    static class IntegrationTestFailure extends Exception {
        IntegrationTestFailure() {
            super();
        }

        IntegrationTestFailure(String message) {
            super(message);
        }

        IntegrationTestFailure(Throwable cause) {
            super(cause);
        }

        IntegrationTestFailure(String message, Throwable cause) {
            super(message, cause);
        }
    }

    /**
     * Returns the map-based value source used to interpolate settings and other stuff.
     *
     * @return the map-based value source for interpolation, never <code>null</code>
     */
    private Map<String, Object> getInterpolationValueSource() {
        Map<String, Object> props = new HashMap<>();
        if (filterProperties != null) {
            props.putAll(filterProperties);
        }
        if (filterProperties != null) {
            props.putAll(filterProperties);
        }
        props.put("basedir", this.project.getBasedir().getAbsolutePath());
        props.put("baseurl", toUrl(this.project.getBasedir().getAbsolutePath()));
        if (settings.getLocalRepository() != null) {
            props.put("localRepository", settings.getLocalRepository());
            props.put("localRepositoryUrl", toUrl(settings.getLocalRepository()));
        }
        return new CompositeMap(this.project, props);
    }

    protected void buildInterpolatedFile(File originalFile, File interpolatedFile) throws MojoExecutionException {
        getLog().debug("Interpolate " + originalFile.getPath() + " to " + interpolatedFile.getPath());

        try {
            String xml;

            // interpolation with token @...@
            Map<String, Object> composite = getInterpolationValueSource();

            try (Reader xmlStreamReader = new XmlStreamReader(originalFile);
                    Reader reader = new InterpolationFilterReader(xmlStreamReader, composite, "@", "@")) {
                xml = IOUtil.toString(reader);
            }

            try (Writer writer = new XmlStreamWriter(interpolatedFile)) {
                interpolatedFile.getParentFile().mkdirs();

                writer.write(xml);
            }
        } catch (IOException e) {
            throw new MojoExecutionException("Failed to interpolate file " + originalFile.getPath(), e);
        }
    }

    private static class CompositeMap implements Map<String, Object> {

        /**
         * The Maven project from which to extract interpolated values, never <code>null</code>.
         */
        private MavenProject mavenProject;

        /**
         * The set of additional properties from which to extract interpolated values, never <code>null</code>.
         */
        private Map<String, Object> properties;

        /**
         * Creates a new interpolation source backed by the specified Maven project and some user-specified properties.
         *
         * @param mavenProject the Maven project from which to extract interpolated values, must not be
         *                     <code>null</code>
         * @param properties   the set of additional properties from which to extract interpolated values, may be
         *                     <code>null</code>
         */
        protected CompositeMap(MavenProject mavenProject, Map<String, Object> properties) {
            if (mavenProject == null) {
                throw new IllegalArgumentException("no project specified");
            }
            this.mavenProject = mavenProject;
            this.properties = properties == null ? new HashMap<>() : properties;
        }

        /**
         * {@inheritDoc}
         *
         * @see java.util.Map#clear()
         */
        @Override
        public void clear() {
            // nothing here
        }

        /**
         * {@inheritDoc}
         *
         * @see java.util.Map#containsKey(java.lang.Object)
         */
        @Override
        public boolean containsKey(Object key) {
            if (!(key instanceof String)) {
                return false;
            }

            String expression = (String) key;
            if (expression.startsWith("project.") || expression.startsWith("pom.")) {
                try {
                    Object evaluated = ReflectionValueExtractor.evaluate(expression, this.mavenProject);
                    if (evaluated != null) {
                        return true;
                    }
                } catch (Exception e) {
                    // uhm do we have to throw a RuntimeException here ?
                }
            }

            return properties.containsKey(key) || mavenProject.getProperties().containsKey(key);
        }

        /**
         * {@inheritDoc}
         *
         * @see java.util.Map#containsValue(java.lang.Object)
         */
        @Override
        public boolean containsValue(Object value) {
            throw new UnsupportedOperationException();
        }

        /**
         * {@inheritDoc}
         *
         * @see java.util.Map#entrySet()
         */
        @Override
        public Set<Entry<String, Object>> entrySet() {
