/*
 * 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.felix.bundleplugin.baseline;

import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.maven.artifact.Artifact;
import org.apache.maven.artifact.factory.ArtifactFactory;
import org.apache.maven.artifact.metadata.ArtifactMetadataRetrievalException;
import org.apache.maven.artifact.metadata.ArtifactMetadataSource;
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.artifact.versioning.ArtifactVersion;
import org.apache.maven.artifact.versioning.InvalidVersionSpecificationException;
import org.apache.maven.artifact.versioning.OverConstrainedVersionException;
import org.apache.maven.artifact.versioning.VersionRange;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.codehaus.plexus.util.StringUtils;
import org.sonatype.plexus.build.incremental.BuildContext;

import aQute.bnd.differ.Baseline;
import aQute.bnd.differ.Baseline.Info;
import aQute.bnd.differ.DiffPluginImpl;
import aQute.bnd.osgi.Instructions;
import aQute.bnd.osgi.Jar;
import aQute.bnd.osgi.Processor;
import aQute.bnd.service.diff.Delta;
import aQute.bnd.service.diff.Diff;
import aQute.bnd.version.Version;
import aQute.service.reporter.Reporter;

/**
 * Abstract BND Baseline check between two bundles.
 */
abstract class AbstractBaselinePlugin
    extends AbstractMojo
{

    /**
     * Flag to easily skip execution.
     */
    @Parameter( property = "baseline.skip", defaultValue = "false" )
    protected boolean skip;

    /**
     * Whether to fail on errors.
     */
    @Parameter( property = "baseline.failOnError", defaultValue = "true" )
    protected boolean failOnError;

    /**
     * Whether to fail on warnings.
     */
    @Parameter( property = "baseline.failOnWarning", defaultValue = "false" )
    protected boolean failOnWarning;

    @Parameter( defaultValue = "${project}", readonly = true, required = true )
    protected MavenProject project;

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

    @Parameter( defaultValue = "${project.build.directory}", readonly = true, required = true )
    private File buildDirectory;

    @Parameter( defaultValue = "${project.build.finalName}", readonly = true, required = true )
    private String finalName;

    @Component
    protected ArtifactResolver resolver;

    @Component
    protected ArtifactFactory factory;

    @Component
    private ArtifactMetadataSource metadataSource;

    /**
     * Group id to compare the current code against.
     */
    @Parameter( defaultValue = "${project.groupId}", property="comparisonGroupId" )
    protected String comparisonGroupId;

    /**
     * Artifact to compare the current code against.
     */
    @Parameter( defaultValue = "${project.artifactId}", property="comparisonArtifactId" )
    protected String comparisonArtifactId;

    /**
     * Version to compare the current code against.
     */
    @Parameter( defaultValue = "(,${project.version})", property="comparisonVersion" )
    protected String comparisonVersion;

    /**
     * Artifact to compare the current code against.
     */
    @Parameter( defaultValue = "${project.packaging}", property="comparisonPackaging" )
    protected String comparisonPackaging;

    /**
     * Classifier for the artifact to compare the current code against.
     */
    @Parameter( property="comparisonClassifier" )
    protected String comparisonClassifier;

    /**
     * A list of packages filter, if empty the whole bundle will be traversed. Values are specified in OSGi package
     * instructions notation, e.g. <code>!org.apache.felix.bundleplugin</code>.
     */
    @Parameter
    private String[] filters;

    /**
     * Project types which this plugin supports.
     */
    @Parameter
    protected List<String> supportedProjectTypes = Arrays.asList("jar", "bundle");

    @Component
    protected BuildContext buildContext;
    
    public final void execute()
        throws MojoExecutionException, MojoFailureException
    {
        this.execute(null);
    }

    protected void execute( Object context)
            throws MojoExecutionException, MojoFailureException
    {
        if ( skip )
        {
            getLog().info( "Skipping Baseline execution" );
            return;
        }

        if ( !supportedProjectTypes.contains( project.getArtifact().getType() ) )
        {
            getLog().info("Skipping Baseline (project type " + project.getArtifact().getType() + " not supported)");
            return;
        }

        // get the bundles that have to be compared

        final Jar currentBundle = getCurrentBundle();
        if ( currentBundle == null )
        {
            getLog().info( "Not generating Baseline report as there is no bundle generated by the project" );
            return;
        }

        final Artifact previousArtifact = getPreviousArtifact();

        final Jar previousBundle;
        if (previousArtifact != null)
        {
            previousBundle = openJar(previousArtifact.getFile());
        }
        else
        {
            previousBundle = null;
        }

        if ( previousBundle == null )
        {
            getLog().info( "Not generating Baseline report as there is no previous version of the library to compare against" );
            return;
        }

        // preparing the filters

        final Instructions packageFilters;
        if ( filters == null || filters.length == 0 )
        {
            packageFilters = new Instructions();
        }
        else
        {
            packageFilters = new Instructions( Arrays.asList( filters ) );
        }


        String generationDate = new SimpleDateFormat( "yyyy-MM-dd'T'HH:mm'Z'" ).format( new Date() );
        final Reporter reporter = new Processor();

        final Info[] infos;
        try
        {
            final Set<Info> infoSet = new Baseline( reporter, new DiffPluginImpl() )
                                .baseline( currentBundle, previousBundle, packageFilters );
            infos = infoSet.toArray( new Info[infoSet.size()] );
            Arrays.sort( infos, new InfoComparator() );
        }
        catch ( final Exception e )
        {
            throw new MojoExecutionException( "Impossible to calculate the baseline", e );
        }
        finally
        {
            closeJars( currentBundle, previousBundle );
        }

        try
        {
            // go!
            context = this.init(context);
            startBaseline( context, generationDate, project.getArtifactId(), project.getVersion(), previousArtifact.getVersion() );

            for ( final Info info : infos )
            {
                DiffMessage diffMessage = null;

                if ( info.suggestedVersion != null )
                {
                    if ( info.newerVersion.compareTo( info.suggestedVersion ) > 0 )
                    {
                        diffMessage = new DiffMessage( "Excessive version increase", DiffMessage.Type.warning );
                        reporter.warning( "%s: %s; detected %s, suggested %s",
                                          info.packageName, diffMessage, info.newerVersion, info.suggestedVersion );
                    }
                    else if ( info.newerVersion.compareTo( info.suggestedVersion ) < 0 )
                    {
                        diffMessage = new DiffMessage( "Version increase required", DiffMessage.Type.error );
                        reporter.error( "%s: %s; detected %s, suggested %s",
                                        info.packageName, diffMessage, info.newerVersion, info.suggestedVersion );
                    }
                }

                switch ( info.packageDiff.getDelta() )
                {
                    case UNCHANGED:
                        if ( info.newerVersion.compareTo( info.suggestedVersion ) != 0 )
                        {
                            diffMessage = new DiffMessage( "Version has been increased but analysis detected no changes", DiffMessage.Type.warning );
                            reporter.warning( "%s: %s; detected %s, suggested %s",
                                              info.packageName, diffMessage, info.newerVersion, info.suggestedVersion );
                        }
                        break;

                    case REMOVED:
                        diffMessage = new DiffMessage( "Package removed", DiffMessage.Type.info );
                        reporter.trace( "%s: %s ", info.packageName, diffMessage );
                        break;

                    case CHANGED:
                    case MICRO:
                    case MINOR:
                    case MAJOR:
                    case ADDED:
                    default:
                        // ok
                        break;
                }

                startPackage( context,
                              info.mismatch,
                              info.packageName,
                              getShortDelta( info.packageDiff.getDelta() ),
                              StringUtils.lowerCase( String.valueOf( info.packageDiff.getDelta() ) ),
                              info.newerVersion,
                              info.olderVersion,
                              info.suggestedVersion,
                              diffMessage,
                              info.attributes );

                if ( Delta.REMOVED != info.packageDiff.getDelta() )
                {
                    doPackageDiff( context, info.packageDiff );
                }

                endPackage(context);
            }

            endBaseline(context);
        }
        finally
        {
            this.close(context);
        }

        // check if it has to fail if some error has been detected

        boolean fail = false;

        if ( !reporter.isOk() )
        {
            for ( String errorMessage : reporter.getErrors() )
            {
                getLog().error( errorMessage );
            }

            if ( failOnError )
            {
                fail = true;
            }
        }

        // check if it has to fail if some warning has been detected

        if ( !reporter.getWarnings().isEmpty() )
        {
            for ( String warningMessage : reporter.getWarnings() )
            {
                getLog().warn( warningMessage );
            }

            if ( failOnWarning )
            {
                fail = true;
            }
        }

        getLog().info( String.format( "Baseline analysis complete, %s error(s), %s warning(s)",
                                      reporter.getErrors().size(),
                                      reporter.getWarnings().size() ) );

        if ( fail )
        {
            throw new MojoFailureException( "Baseline failed, see generated report" );
        }
    }

    private void doPackageDiff( Object context, Diff diff )
    {
        int depth = 1;

        for ( Diff curDiff : diff.getChildren() )
        {
            if ( Delta.UNCHANGED != curDiff.getDelta() )
            {
                doDiff( context, curDiff, depth );
            }
        }
    }

    private void doDiff( Object context, Diff diff, int depth )
    {
        String type = StringUtils.lowerCase( String.valueOf( diff.getType() ) );
        String shortDelta = getShortDelta( diff.getDelta() );
        String delta = StringUtils.lowerCase( String.valueOf( diff.getDelta() ) );
        String name = diff.getName();

        startDiff( context, depth, type, name, delta, shortDelta );

        for ( Diff curDiff : diff.getChildren() )
        {
            if ( Delta.UNCHANGED != curDiff.getDelta() )
            {
                doDiff( context, curDiff, depth + 1 );
            }
        }

        endDiff( context, depth );
    }

    // extensions APIs

    protected abstract Object init(final Object initialContext);

    protected abstract void close(final Object context);

    protected abstract void startBaseline( final Object context, String generationDate, String bundleName, String currentVersion, String previousVersion );

    protected abstract void startPackage( final Object context,
            boolean mismatch,
                                          String name,
                                          String shortDelta,
                                          String delta,
                                          Version newerVersion,
                                          Version olderVersion,
                                          Version suggestedVersion,
                                          DiffMessage diffMessage,
                                          Map<String,String> attributes );

    protected abstract void startDiff( final Object context,
                                       int depth,
                                       String type,
                                       String name,
                                       String delta,
                                       String shortDelta );

    protected abstract void endDiff( final Object context, int depth );

    protected abstract void endPackage(final Object context);

    protected abstract void endBaseline(final Object context);

    // internals

    private Jar getCurrentBundle()
        throws MojoExecutionException
    {
        /*
         * Resolving the aQute.bnd.osgi.Jar via the produced artifact rather than what is produced in the target/classes
         * directory would make the Mojo working also in projects where the bundle-plugin is used just to generate the
         * manifest file and the final jar is assembled via the jar-plugin
         */
        File currentBundle = new File( buildDirectory, getBundleName() );
        if ( !currentBundle.exists() )
        {
            getLog().debug( "Produced bundle not found: " + currentBundle );
            return null;
        }

        return openJar( currentBundle );
    }

    private Artifact getPreviousArtifact()
        throws MojoFailureException, MojoExecutionException
    {
        // Find the previous version JAR and resolve it, and it's dependencies
        final VersionRange range;
        try
        {
            range = VersionRange.createFromVersionSpec( comparisonVersion );
        }
        catch ( InvalidVersionSpecificationException e )
        {
            throw new MojoFailureException( "Invalid comparison version: " + e.getMessage() );
        }

        final Artifact previousArtifact;
        try
        {
            previousArtifact =
                factory.createDependencyArtifact( comparisonGroupId,
                                                  comparisonArtifactId,
                                                  range,
                                                  comparisonPackaging,
                                                  comparisonClassifier,
                                                  Artifact.SCOPE_COMPILE );

            if ( !previousArtifact.getVersionRange().isSelectedVersionKnown( previousArtifact ) )
            {
                getLog().debug( "Searching for versions in range: " + previousArtifact.getVersionRange() );
                @SuppressWarnings( "unchecked" )
                // type is konwn
                List<ArtifactVersion> availableVersions =
                    metadataSource.retrieveAvailableVersions( previousArtifact, session.getLocalRepository(),
                                                              project.getRemoteArtifactRepositories() );
                filterSnapshots( availableVersions );
                ArtifactVersion version = range.matchVersion( availableVersions );
                if ( version != null )
                {
                    previousArtifact.selectVersion( version.toString() );
                }
            }
        }
        catch ( OverConstrainedVersionException ocve )
        {
            throw new MojoFailureException( "Invalid comparison version: " + ocve.getMessage() );
        }
        catch ( ArtifactMetadataRetrievalException amre )
        {
            throw new MojoExecutionException( "Error determining previous version: " + amre.getMessage(), amre );
        }

        if ( previousArtifact.getVersion() == null )
        {
            getLog().info( "Unable to find a previous version of the project in the repository" );
            return null;
        }

        try
        {
            resolver.resolve( previousArtifact, project.getRemoteArtifactRepositories(), session.getLocalRepository() );
        }
        catch ( ArtifactResolutionException are )
        {
            throw new MojoExecutionException( "Artifact " + previousArtifact + " cannot be resolved : " + are.getMessage(), are );
        }
        catch ( ArtifactNotFoundException anfe )
        {
            throw new MojoExecutionException( "Artifact " + previousArtifact
                + " does not exist on local/remote repositories", anfe );
        }

        return previousArtifact;
    }

    private void filterSnapshots( List<ArtifactVersion> versions )
    {
        for ( Iterator<ArtifactVersion> versionIterator = versions.iterator(); versionIterator.hasNext(); )
        {
            ArtifactVersion version = versionIterator.next();
            if ( version.getQualifier() != null && version.getQualifier().endsWith( "SNAPSHOT" ) )
            {
                versionIterator.remove();
            }
        }
    }

    private static Jar openJar( final File file )
        throws MojoExecutionException
    {
        try
        {
            return new Jar( file );
        }
        catch ( final IOException e )
        {
            throw new MojoExecutionException( "An error occurred while opening JAR directory: " + file, e );
        }
    }

    private static void closeJars( final Jar...jars )
    {
        for ( Jar jar : jars )
        {
            jar.close();
        }
    }

    private String getBundleName()
    {
        String extension;
        try
        {
            extension = project.getArtifact().getArtifactHandler().getExtension();
        }
        catch ( Throwable e )
        {
            extension = project.getArtifact().getType();
        }

        if ( StringUtils.isEmpty( extension ) || "bundle".equals( extension ) || "pom".equals( extension ) )
        {
            extension = "jar"; // just in case maven gets confused
        }

        String classifier = this.comparisonClassifier != null ? this.comparisonClassifier : project.getArtifact().getClassifier();
        if ( null != classifier && classifier.trim().length() > 0 )
        {
            return finalName + '-' + classifier + '.' + extension;
        }

        return finalName + '.' + extension;
    }

    private static String getShortDelta( Delta delta )
    {
        switch ( delta )
        {
            case ADDED:
                return "+";

            case CHANGED:
                return "~";

            case MAJOR:
                return ">";

            case MICRO:
                return "0xB5";

            case MINOR:
                return "<";

            case REMOVED:
                return "-";

            case UNCHANGED:
                return " ";

            default:
                String deltaString = delta.toString();
                return String.valueOf( deltaString.charAt( 0 ) );
        }
    }
}
