/*
 * Copyright 2014-2019 Grzegorz Slowikowski (gslowikowski at gmail dot com)
 *
 * Licensed 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.scoverage.plugin;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;

import org.apache.maven.artifact.Artifact;
import org.apache.maven.artifact.factory.ArtifactFactory;
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.artifact.resolver.ArtifactNotFoundException;
import org.apache.maven.artifact.resolver.ArtifactResolutionException;
import org.apache.maven.artifact.resolver.ArtifactResolver;
import org.apache.maven.model.Dependency;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;

import org.codehaus.plexus.util.StringUtils;

/**
 * Configures project for compilation with SCoverage instrumentation.
 * <br>
 * <br>
 * Supported compiler plugins:
 * <ul>
 * <li><a href="https://davidb.github.io/scala-maven-plugin/">net.alchim31.maven:scala-maven-plugin</a></li>
 * <li><a href="https://sbt-compiler-maven-plugin.github.io/sbt-compiler-maven-plugin/">com.google.code.sbt-compiler-maven-plugin:sbt-compiler-maven-plugin</a></li>
 * </ul>
 * <br>
 * This is internal mojo, executed in forked {@code scoverage} life cycle.
 * <br>
 * 
 * @author <a href="mailto:[email protected]">Grzegorz Slowikowski</a>
 * @since 1.0.0
 */
@Mojo( name = "pre-compile", defaultPhase = LifecyclePhase.GENERATE_RESOURCES ) 
public class SCoveragePreCompileMojo
    extends AbstractMojo
{

    /**
     * Allows SCoverage to be skipped.
     * <br>
     * 
     * @since 1.0.0
     */
    @Parameter( property = "scoverage.skip", defaultValue = "false" )
    private boolean skip;

    /**
     * Scala version used for compiler plugin artifact resolution.
     * <ul>
     * <li>if specified, and equals {@code 2.10} or starts with {@code 2.10.} - <b>{@code scalac-scoverage-plugin_2.10}</b> will be used</li>
     * <li>if specified, and equals {@code 2.11} or starts with {@code 2.11.} - <b>{@code scalac-scoverage-plugin_2.11}</b> will be used</li>
     * <li>if specified, and equals {@code 2.12} or starts with {@code 2.12.} - <b>{@code scalac-scoverage-plugin_2.12}</b> will be used</li>
     * <li>if specified, and equals {@code 2.13} or starts with {@code 2.13.} - <b>{@code scalac-scoverage-plugin_2.13}</b> will be used</li>
     * <li>if specified, but does not meet any of the above conditions or if not specified - plugin execution will be skipped</li>
     * </ul>
     * 
     * @since 1.0.0
     */
    @Parameter( property = "scala.version" )
    private String scalaVersion;

    /**
     * Directory where the coverage files should be written.
     * <br>
     *
     * @since 1.0.0
     */
    @Parameter( property = "scoverage.dataDirectory", defaultValue = "${project.build.directory}/scoverage-data", required = true, readonly = true )
    private File dataDirectory;

    /**
     * Semicolon-separated list of regular expressions for packages to exclude, "(empty)" for default package.
     * <br>
     * <br>
     * Example:
     * <br>
     * {@code (empty);Reverse.*;.*AuthService.*;models\.data\..*}
     * <br>
     * <br>
     * See <a href="https://github.com/scoverage/sbt-scoverage#exclude-classes-and-packages">https://github.com/scoverage/sbt-scoverage#exclude-classes-and-packages</a> for additional documentation.
     * <br>
     *
     * @since 1.0.0
     */
    @Parameter( property = "scoverage.excludedPackages", defaultValue = "" )
    private String excludedPackages;

    /**
     * Semicolon-separated list of regular expressions for source paths to exclude.
     * <br>
     *
     * @since 1.0.0
     */
    @Parameter( property = "scoverage.excludedFiles", defaultValue = "" )
    private String excludedFiles;

    /**
     * See <a href="https://github.com/scoverage/sbt-scoverage#highlighting">https://github.com/scoverage/sbt-scoverage#highlighting</a>.
     * <br>
     * 
     * @since 1.0.0
     */
    @Parameter( property = "scoverage.highlighting", defaultValue = "true" )
    private boolean highlighting;

    /**
     * Force <a href="https://github.com/scoverage/scalac-scoverage-plugin">scalac-scoverage-plugin</a> version used.
     * <br>
     *
     * @since 1.0.0
     */
    @Parameter( property = "scoverage.scalacPluginVersion", defaultValue = "" )
    private String scalacPluginVersion;

    /**
     * Semicolon-separated list of project properties set in forked {@code scoverage} life cycle.
     * <br>
     * <br>
     * Example:
     * <br>
     * {@code prop1=val1;prop2=val2;prop3=val3}
     * <br>
     *
     * @since 1.4.0
     */
    @Parameter( property = "scoverage.additionalForkedProjectProperties", defaultValue = "" )
    private String additionalForkedProjectProperties;

    /**
     * Maven project to interact with.
     */
    @Parameter( defaultValue = "${project}", readonly = true, required = true )
    private MavenProject project;

    /**
     * All Maven projects in the reactor.
     */
    @Parameter( defaultValue = "${reactorProjects}", required = true, readonly = true )
    private List<MavenProject> reactorProjects;

    /**
     * Artifact factory used to look up artifacts in the remote repository.
     */
    @Component
    private ArtifactFactory factory;

    /**
     * Artifact resolver used to resolve artifacts.
     */
    @Component
    private ArtifactResolver resolver;

    /**
     * Location of the local repository.
     */
    @Parameter( property = "localRepository", readonly = true, required = true )
    private ArtifactRepository localRepo;

    /**
     * Remote repositories used by the resolver
     */
    @Parameter( property = "project.remoteArtifactRepositories", readonly = true, required = true )
    private List<ArtifactRepository> remoteRepos;

    /**
     * List of artifacts this plugin depends on.
     */
    @Parameter( property = "plugin.artifacts", readonly = true, required = true )
    private List<Artifact> pluginArtifacts;

    /**
     * Configures project for compilation with SCoverage instrumentation.
     * 
     * @throws MojoExecutionException if unexpected problem occurs
     */
    @Override
    public void execute() throws MojoExecutionException
    {
        if ( "pom".equals( project.getPackaging() ) )
        {
            getLog().info( "Skipping SCoverage execution for project with packaging type 'pom'" );
            //for aggragetor mojo - list of submodules: List<MavenProject> modules = project.getCollectedProjects();
            return;
        }

        if ( skip )
        {
            getLog().info( "Skipping Scoverage execution" );

            Properties projectProperties = project.getProperties();

            // for maven-resources-plugin (testResources), maven-compiler-plugin (testCompile),
            // sbt-compiler-maven-plugin (testCompile), scala-maven-plugin (testCompile),
            // maven-surefire-plugin and scalatest-maven-plugin
            setProperty( projectProperties, "maven.test.skip", "true" );
            // for scalatest-maven-plugin and specs2-maven-plugin
            setProperty( projectProperties, "skipTests", "true" );

            return;
        }

        long ts = System.currentTimeMillis();

        String scalaBinaryVersion = null;
        String resolvedScalaVersion = resolveScalaVersion();
        if ( resolvedScalaVersion != null )
        {
            if ( "2.10".equals( resolvedScalaVersion ) || resolvedScalaVersion.startsWith( "2.10." ) )
            {
                scalaBinaryVersion = "2.10";
            }
            else if ( "2.11".equals( resolvedScalaVersion ) || resolvedScalaVersion.startsWith( "2.11." ) )
            {
                scalaBinaryVersion = "2.11";
            }
            else if ( "2.12".equals( resolvedScalaVersion ) || resolvedScalaVersion.startsWith( "2.12." ) )
            {
                scalaBinaryVersion = "2.12";
            }
            else if ( "2.13".equals( resolvedScalaVersion ) || resolvedScalaVersion.startsWith( "2.13." ) )
            {
                scalaBinaryVersion = "2.13";
            }
            else
            {
                getLog().warn( String.format( "Skipping SCoverage execution - unsupported Scala version \"%s\"",
                                              resolvedScalaVersion ) );
                return;
            }
        }
        else
        {
            getLog().warn( "Skipping SCoverage execution - Scala version not set" );
            return;
        }

        Map<String, String> additionalProjectPropertiesMap = null;
        if ( additionalForkedProjectProperties != null && !additionalForkedProjectProperties.isEmpty() )
        {
            String[] props = additionalForkedProjectProperties.split( ";" );
            additionalProjectPropertiesMap = new HashMap<String, String>( props.length );
            for ( String propVal: props )
            {
                String[] tmp = propVal.split( "=", 2 );
                if ( tmp.length == 2 )
                {
                    String propName = tmp[ 0 ].trim();
                    String propValue = tmp[ 1 ].trim();
                    additionalProjectPropertiesMap.put( propName, propValue );
                }
                else
                {
                    getLog().warn( String.format( "Skipping invalid additional forked project property \"%s\", must be in \"key=value\" format",
                            propVal ) );

                }
            }
        }

        SCoverageForkedLifecycleConfigurator.afterForkedLifecycleEnter( project, reactorProjects, additionalProjectPropertiesMap );

        try
        {
            Artifact pluginArtifact = getScalaScoveragePluginArtifact( scalaBinaryVersion );
            Artifact runtimeArtifact = getScalaScoverageRuntimeArtifact( scalaBinaryVersion );

            if ( pluginArtifact == null )
            {
                return; // scoverage plugin will not be configured
            }

            addScoverageDependenciesToClasspath( runtimeArtifact );

            String arg = DATA_DIR_OPTION + dataDirectory.getAbsolutePath();
            String _scalacOptions = quoteArgument( arg );
            String addScalacArgs = arg;

            if ( !StringUtils.isEmpty( excludedPackages ) )
            {
                arg = EXCLUDED_PACKAGES_OPTION + excludedPackages.replace( "(empty)", "<empty>" );
                _scalacOptions = _scalacOptions + SPACE + quoteArgument( arg );
                addScalacArgs = addScalacArgs + PIPE + arg;
            }

            if ( !StringUtils.isEmpty( excludedFiles ) )
            {
                arg = EXCLUDED_FILES_OPTION + excludedFiles;
                _scalacOptions = _scalacOptions + SPACE + quoteArgument( arg );
                addScalacArgs = addScalacArgs + PIPE + arg;
            }

            if ( highlighting )
            {
                _scalacOptions = _scalacOptions + SPACE + "-Yrangepos";
                addScalacArgs = addScalacArgs + PIPE + "-Yrangepos";
            }

            String _scalacPlugins =
                String.format( "%s:%s:%s", pluginArtifact.getGroupId(), pluginArtifact.getArtifactId(),
                               pluginArtifact.getVersion() );

            arg = PLUGIN_OPTION + pluginArtifact.getFile().getAbsolutePath();
            addScalacArgs = addScalacArgs + PIPE + arg;

            Properties projectProperties = project.getProperties();

            // for sbt-compiler-maven-plugin (version 1.0.0-beta5+)
            setProperty( projectProperties, "sbt._scalacOptions", _scalacOptions );
            // for sbt-compiler-maven-plugin (version 1.0.0-beta5+)
            setProperty( projectProperties, "sbt._scalacPlugins", _scalacPlugins );
            // for scala-maven-plugin (version 3.0.0+)
            setProperty( projectProperties, "addScalacArgs", addScalacArgs );
            // for scala-maven-plugin (version 3.1.0+)
            setProperty( projectProperties, "analysisCacheFile",
                         "${project.build.directory}/scoverage-analysis/compile" );
            // for maven-surefire-plugin and scalatest-maven-plugin
            setProperty( projectProperties, "maven.test.failure.ignore", "true" );

            // for maven-jar-plugin
            // VERY IMPORTANT! Prevents from overwriting regular project artifact file
            // with instrumented one during "integration-check" or "integration-report" execution.
            project.getBuild().setFinalName( "scoverage-" + project.getBuild().getFinalName() );

            saveSourceRootsToFile();
        }
        catch ( ArtifactNotFoundException e )
        {
            throw new MojoExecutionException( "SCoverage preparation failed", e );
        }
        catch ( ArtifactResolutionException e )
        {
            throw new MojoExecutionException( "SCoverage preparation failed", e );
        }
        catch ( IOException e )
        {
            throw new MojoExecutionException( "SCoverage preparation failed", e );
        }

        long te = System.currentTimeMillis();
        getLog().debug( String.format( "Mojo execution time: %d ms", te - ts ) );
    }

    // Private utility methods

    private static final String SCALA_LIBRARY_GROUP_ID = "org.scala-lang";
    private static final String SCALA_LIBRARY_ARTIFACT_ID = "scala-library";

    private static final String DATA_DIR_OPTION = "-P:scoverage:dataDir:";
    private static final String EXCLUDED_PACKAGES_OPTION = "-P:scoverage:excludedPackages:";
    private static final String EXCLUDED_FILES_OPTION = "-P:scoverage:excludedFiles:";
    private static final String PLUGIN_OPTION = "-Xplugin:";

    private static final char DOUBLE_QUOTE = '\"';
    private static final char SPACE = ' ';
    private static final char PIPE = '|';

    private String quoteArgument( String arg )
    {
        return arg.indexOf( SPACE ) >= 0 ? DOUBLE_QUOTE + arg + DOUBLE_QUOTE : arg;
    }

    private String resolveScalaVersion()
    {
        String result = scalaVersion;
        if ( result == null )
        {
            // check project direct dependencies (transitive dependencies cannot be checked in this Maven lifecycle phase)
            @SuppressWarnings( "unchecked" )
            List<Dependency> dependencies = project.getDependencies();
            for ( Dependency dependency: dependencies )
            {
                if ( SCALA_LIBRARY_GROUP_ID.equals( dependency.getGroupId() )
                    && SCALA_LIBRARY_ARTIFACT_ID.equals( dependency.getArtifactId() ) )
                {
                    result = dependency.getVersion();
                    break;
                }
            }
        }
        return result;
    }

    private void setProperty( Properties projectProperties, String propertyName, String newValue )
    {
        if ( projectProperties.containsKey( propertyName ) )
        {
            String oldValue = projectProperties.getProperty( propertyName );
            projectProperties.put( "scoverage.backup." + propertyName, oldValue );
        }
        else
        {
            projectProperties.remove( "scoverage.backup." + propertyName );
        }

        if ( newValue != null )
        {
            projectProperties.put( propertyName, newValue );
        }
        else
        {
            projectProperties.remove( propertyName );
        }
    }

    private Artifact getScalaScoveragePluginArtifact( String scalaMainVersion )
        throws ArtifactNotFoundException, ArtifactResolutionException
    {
        Artifact result = null;

        String resolvedScalacPluginVersion = scalacPluginVersion;
        if ( resolvedScalacPluginVersion == null || "".equals( resolvedScalacPluginVersion ) )
        {
            for ( Artifact artifact : pluginArtifacts )
            {
                if ( "org.scoverage".equals( artifact.getGroupId() )
                    && "scalac-scoverage-plugin_2.12".equals( artifact.getArtifactId() ) )
                {
                    if ( "2.12".equals( scalaMainVersion ) )
                    {
                        return artifact; // shortcut, use the same artifact plugin uses
                    }
                    resolvedScalacPluginVersion = artifact.getVersion();
                    break;
                }
            }
        }

        result =
            getResolvedArtifact( "org.scoverage", "scalac-scoverage-plugin_" + scalaMainVersion,
                                 resolvedScalacPluginVersion );
        return result;
    }

    private Artifact getScalaScoverageRuntimeArtifact( String scalaMainVersion )
        throws ArtifactNotFoundException, ArtifactResolutionException
    {
        Artifact result = null;

        String resolvedScalacRuntimeVersion = scalacPluginVersion;
        if ( resolvedScalacRuntimeVersion == null || "".equals( resolvedScalacRuntimeVersion ) )
        {
            for ( Artifact artifact : pluginArtifacts )
            {
                if ( "org.scoverage".equals( artifact.getGroupId() )
                    && "scalac-scoverage-plugin_2.12".equals( artifact.getArtifactId() ) )
                {
                    resolvedScalacRuntimeVersion = artifact.getVersion();
                    break;
                }
            }
        }

        result =
            getResolvedArtifact( "org.scoverage", "scalac-scoverage-runtime_" + scalaMainVersion,
                                 resolvedScalacRuntimeVersion );
        return result;
    }

    /**
     * We need to tweak our test classpath for Scoverage.
     *
     * @throws MojoExecutionException
     */
    private void addScoverageDependenciesToClasspath( Artifact scalaScoveragePluginArtifact )
        throws MojoExecutionException
    {
        @SuppressWarnings( "unchecked" )
        Set<Artifact> set = new LinkedHashSet<Artifact>( project.getDependencyArtifacts() );
        set.add( scalaScoveragePluginArtifact );
        project.setDependencyArtifacts( set );
    }

    private Artifact getResolvedArtifact( String groupId, String artifactId, String version )
        throws ArtifactNotFoundException, ArtifactResolutionException
    {
        Artifact artifact = factory.createArtifact( groupId, artifactId, version, Artifact.SCOPE_COMPILE, "jar" );
        resolver.resolve( artifact, remoteRepos, localRepo );
        return artifact;
    }

    private void saveSourceRootsToFile() throws IOException
    {
        List<String> sourceRoots = project.getCompileSourceRoots();
        if ( !sourceRoots.isEmpty() )
        {
            if ( !dataDirectory.exists() && !dataDirectory.mkdirs() )
            {
                throw new IOException( String.format( "Cannot create \"%s\" directory ",
                        dataDirectory.getAbsolutePath() ) );
            }
            File sourceRootsFile = new File( dataDirectory, "source.roots" );
            BufferedWriter writer = new BufferedWriter(
                    new OutputStreamWriter( new FileOutputStream( sourceRootsFile ), "UTF-8" ) );
            try
            {
                for ( String sourceRoot: sourceRoots )
                {
                    writer.write( sourceRoot );
                    writer.newLine();
                }
            }
            finally
            {
                writer.close();
            }
        }
    }

}