package org.eclipse.aether.internal.impl;

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

import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static java.util.Objects.requireNonNull;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

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

import org.eclipse.aether.RepositoryEvent;
import org.eclipse.aether.RepositoryEvent.EventType;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.RequestTrace;
import org.eclipse.aether.SyncContext;
import org.eclipse.aether.impl.MetadataResolver;
import org.eclipse.aether.impl.OfflineController;
import org.eclipse.aether.impl.RemoteRepositoryManager;
import org.eclipse.aether.impl.RepositoryConnectorProvider;
import org.eclipse.aether.impl.RepositoryEventDispatcher;
import org.eclipse.aether.impl.SyncContextFactory;
import org.eclipse.aether.impl.UpdateCheck;
import org.eclipse.aether.impl.UpdateCheckManager;
import org.eclipse.aether.metadata.Metadata;
import org.eclipse.aether.repository.ArtifactRepository;
import org.eclipse.aether.repository.LocalMetadataRegistration;
import org.eclipse.aether.repository.LocalMetadataRequest;
import org.eclipse.aether.repository.LocalMetadataResult;
import org.eclipse.aether.repository.LocalRepository;
import org.eclipse.aether.repository.LocalRepositoryManager;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.repository.RepositoryPolicy;
import org.eclipse.aether.resolution.MetadataRequest;
import org.eclipse.aether.resolution.MetadataResult;
import org.eclipse.aether.spi.connector.MetadataDownload;
import org.eclipse.aether.spi.connector.RepositoryConnector;
import org.eclipse.aether.spi.locator.Service;
import org.eclipse.aether.spi.locator.ServiceLocator;
import org.eclipse.aether.transfer.MetadataNotFoundException;
import org.eclipse.aether.transfer.MetadataTransferException;
import org.eclipse.aether.transfer.NoRepositoryConnectorException;
import org.eclipse.aether.transfer.RepositoryOfflineException;
import org.eclipse.aether.util.ConfigUtils;
import org.eclipse.aether.util.concurrency.RunnableErrorForwarder;
import org.eclipse.aether.util.concurrency.WorkerThreadFactory;

/**
 */
@Named
public class DefaultMetadataResolver
    implements MetadataResolver, Service
{

    private static final String CONFIG_PROP_THREADS = "aether.metadataResolver.threads";

    private RepositoryEventDispatcher repositoryEventDispatcher;

    private UpdateCheckManager updateCheckManager;

    private RepositoryConnectorProvider repositoryConnectorProvider;

    private RemoteRepositoryManager remoteRepositoryManager;

    private SyncContextFactory syncContextFactory;

    private OfflineController offlineController;

    public DefaultMetadataResolver()
    {
        // enables default constructor
    }

    @Inject
    DefaultMetadataResolver( RepositoryEventDispatcher repositoryEventDispatcher,
                             UpdateCheckManager updateCheckManager,
                             RepositoryConnectorProvider repositoryConnectorProvider,
                             RemoteRepositoryManager remoteRepositoryManager, SyncContextFactory syncContextFactory,
                             OfflineController offlineController )
    {
        setRepositoryEventDispatcher( repositoryEventDispatcher );
        setUpdateCheckManager( updateCheckManager );
        setRepositoryConnectorProvider( repositoryConnectorProvider );
        setRemoteRepositoryManager( remoteRepositoryManager );
        setSyncContextFactory( syncContextFactory );
        setOfflineController( offlineController );
    }

    public void initService( ServiceLocator locator )
    {
        setRepositoryEventDispatcher( locator.getService( RepositoryEventDispatcher.class ) );
        setUpdateCheckManager( locator.getService( UpdateCheckManager.class ) );
        setRepositoryConnectorProvider( locator.getService( RepositoryConnectorProvider.class ) );
        setRemoteRepositoryManager( locator.getService( RemoteRepositoryManager.class ) );
        setSyncContextFactory( locator.getService( SyncContextFactory.class ) );
        setOfflineController( locator.getService( OfflineController.class ) );
    }

    public DefaultMetadataResolver setRepositoryEventDispatcher( RepositoryEventDispatcher repositoryEventDispatcher )
    {
        this.repositoryEventDispatcher = requireNonNull(
                repositoryEventDispatcher, "repository event dispatcher cannot be null" );
        return this;
    }

    public DefaultMetadataResolver setUpdateCheckManager( UpdateCheckManager updateCheckManager )
    {
        this.updateCheckManager = requireNonNull( updateCheckManager, "update check manager cannot be null" );
        return this;
    }

    public DefaultMetadataResolver setRepositoryConnectorProvider(
            RepositoryConnectorProvider repositoryConnectorProvider )
    {
        this.repositoryConnectorProvider = requireNonNull(
                repositoryConnectorProvider, "repository connector provider cannot be null" );
        return this;
    }

    public DefaultMetadataResolver setRemoteRepositoryManager( RemoteRepositoryManager remoteRepositoryManager )
    {
        this.remoteRepositoryManager = requireNonNull(
                remoteRepositoryManager, "remote repository provider cannot be null" );
        return this;
    }

    public DefaultMetadataResolver setSyncContextFactory( SyncContextFactory syncContextFactory )
    {
        this.syncContextFactory = requireNonNull( syncContextFactory, "sync context factory cannot be null" );
        return this;
    }

    public DefaultMetadataResolver setOfflineController( OfflineController offlineController )
    {
        this.offlineController = requireNonNull( offlineController, "offline controller cannot be null" );
        return this;
    }

    public List<MetadataResult> resolveMetadata( RepositorySystemSession session,
                                                 Collection<? extends MetadataRequest> requests )
    {

        try ( SyncContext syncContext = syncContextFactory.newInstance( session, false ) )
        {
            Collection<Metadata> metadata = new ArrayList<>( requests.size() );
            for ( MetadataRequest request : requests )
            {
                metadata.add( request.getMetadata() );
            }

            syncContext.acquire( null, metadata );

            return resolve( session, requests );
        }
    }

    @SuppressWarnings( "checkstyle:methodlength" )
    private List<MetadataResult> resolve( RepositorySystemSession session,
                                          Collection<? extends MetadataRequest> requests )
    {
        List<MetadataResult> results = new ArrayList<>( requests.size() );

        List<ResolveTask> tasks = new ArrayList<>( requests.size() );

        Map<File, Long> localLastUpdates = new HashMap<>();

        for ( MetadataRequest request : requests )
        {
            RequestTrace trace = RequestTrace.newChild( request.getTrace(), request );

            MetadataResult result = new MetadataResult( request );
            results.add( result );

            Metadata metadata = request.getMetadata();
            RemoteRepository repository = request.getRepository();

            if ( repository == null )
            {
                LocalRepository localRepo = session.getLocalRepositoryManager().getRepository();

                metadataResolving( session, trace, metadata, localRepo );

                File localFile = getLocalFile( session, metadata );

                if ( localFile != null )
                {
                    metadata = metadata.setFile( localFile );
                    result.setMetadata( metadata );
                }
                else
                {
                    result.setException( new MetadataNotFoundException( metadata, localRepo ) );
                }

                metadataResolved( session, trace, metadata, localRepo, result.getException() );
                continue;
            }

            List<RemoteRepository> repositories = getEnabledSourceRepositories( repository, metadata.getNature() );

            if ( repositories.isEmpty() )
            {
                continue;
            }

            metadataResolving( session, trace, metadata, repository );
            LocalRepositoryManager lrm = session.getLocalRepositoryManager();
            LocalMetadataRequest localRequest =
                new LocalMetadataRequest( metadata, repository, request.getRequestContext() );
            LocalMetadataResult lrmResult = lrm.find( session, localRequest );

            File metadataFile = lrmResult.getFile();

            try
            {
                Utils.checkOffline( session, offlineController, repository );
            }
            catch ( RepositoryOfflineException e )
            {
                if ( metadataFile != null )
                {
                    metadata = metadata.setFile( metadataFile );
                    result.setMetadata( metadata );
                }
                else
                {
                    String msg =
                        "Cannot access " + repository.getId() + " (" + repository.getUrl()
                            + ") in offline mode and the metadata " + metadata
                            + " has not been downloaded from it before";
                    result.setException( new MetadataNotFoundException( metadata, repository, msg, e ) );
                }

                metadataResolved( session, trace, metadata, repository, result.getException() );
                continue;
            }

            Long localLastUpdate = null;
            if ( request.isFavorLocalRepository() )
            {
                File localFile = getLocalFile( session, metadata );
                localLastUpdate = localLastUpdates.get( localFile );
                if ( localLastUpdate == null )
                {
                    localLastUpdate = localFile != null ? localFile.lastModified() : 0;
                    localLastUpdates.put( localFile, localLastUpdate );
                }
            }

            List<UpdateCheck<Metadata, MetadataTransferException>> checks = new ArrayList<>();
            Exception exception = null;
            for ( RemoteRepository repo : repositories )
            {
                UpdateCheck<Metadata, MetadataTransferException> check = new UpdateCheck<>();
                check.setLocalLastUpdated( ( localLastUpdate != null ) ? localLastUpdate : 0 );
                check.setItem( metadata );

                // use 'main' installation file for the check (-> use requested repository)
                File checkFile = new File(
                        session.getLocalRepository().getBasedir(),
                        session.getLocalRepositoryManager()
                                .getPathForRemoteMetadata( metadata, repository, request.getRequestContext() ) );
                check.setFile( checkFile );
                check.setRepository( repository );
                check.setAuthoritativeRepository( repo );
                check.setPolicy( getPolicy( session, repo, metadata.getNature() ).getUpdatePolicy() );

                if ( lrmResult.isStale() )
                {
                    checks.add( check );
                }
                else
                {
                    updateCheckManager.checkMetadata( session, check );
                    if ( check.isRequired() )
                    {
                        checks.add( check );
                    }
                    else if ( exception == null )
                    {
                        exception = check.getException();
                    }
                }
            }

            if ( !checks.isEmpty() )
            {
                RepositoryPolicy policy = getPolicy( session, repository, metadata.getNature() );

                // install path may be different from lookup path
                File installFile = new File(
                        session.getLocalRepository().getBasedir(),
                        session.getLocalRepositoryManager().getPathForRemoteMetadata(
                                metadata, request.getRepository(), request.getRequestContext() ) );

                ResolveTask task =
                    new ResolveTask( session, trace, result, installFile, checks, policy.getChecksumPolicy() );
                tasks.add( task );
            }
            else
            {
                result.setException( exception );
                if ( metadataFile != null )
                {
                    metadata = metadata.setFile( metadataFile );
                    result.setMetadata( metadata );
                }
                metadataResolved( session, trace, metadata, repository, result.getException() );
            }
        }

        if ( !tasks.isEmpty() )
        {
            int threads = ConfigUtils.getInteger( session, 4, CONFIG_PROP_THREADS );
            Executor executor = getExecutor( Math.min( tasks.size(), threads ) );
            try
            {
                RunnableErrorForwarder errorForwarder = new RunnableErrorForwarder();

                for ( ResolveTask task : tasks )
                {
                    executor.execute( errorForwarder.wrap( task ) );
                }

                errorForwarder.await();

                for ( ResolveTask task : tasks )
                {
                    task.result.setException( task.exception );
                }
            }
            finally
            {
                shutdown( executor );
            }
            for ( ResolveTask task : tasks )
            {
                Metadata metadata = task.request.getMetadata();
                // re-lookup metadata for resolve
                LocalMetadataRequest localRequest = new LocalMetadataRequest(
                        metadata, task.request.getRepository(), task.request.getRequestContext() );
                File metadataFile = session.getLocalRepositoryManager().find( session, localRequest ).getFile();
                if ( metadataFile != null )
                {
                    metadata = metadata.setFile( metadataFile );
                    task.result.setMetadata( metadata );
                }
                if ( task.result.getException() == null )
                {
                    task.result.setUpdated( true );
                }
                metadataResolved( session, task.trace, metadata, task.request.getRepository(),
                                  task.result.getException() );
            }
        }

        return results;
    }

    private File getLocalFile( RepositorySystemSession session, Metadata metadata )
    {
        LocalRepositoryManager lrm = session.getLocalRepositoryManager();
        LocalMetadataResult localResult = lrm.find( session, new LocalMetadataRequest( metadata, null, null ) );
        return localResult.getFile();
    }

    private List<RemoteRepository> getEnabledSourceRepositories( RemoteRepository repository, Metadata.Nature nature )
    {
        List<RemoteRepository> repositories = new ArrayList<>();

        if ( repository.isRepositoryManager() )
        {
            for ( RemoteRepository repo : repository.getMirroredRepositories() )
            {
                if ( isEnabled( repo, nature ) )
                {
                    repositories.add( repo );
                }
            }
        }
        else if ( isEnabled( repository, nature ) )
        {
            repositories.add( repository );
        }

        return repositories;
    }

    private boolean isEnabled( RemoteRepository repository, Metadata.Nature nature )
    {
        if ( !Metadata.Nature.SNAPSHOT.equals( nature ) && repository.getPolicy( false ).isEnabled() )
        {
            return true;
        }
        if ( !Metadata.Nature.RELEASE.equals( nature ) && repository.getPolicy( true ).isEnabled() )
        {
            return true;
        }
        return false;
    }

    private RepositoryPolicy getPolicy( RepositorySystemSession session, RemoteRepository repository,
                                        Metadata.Nature nature )
    {
        boolean releases = !Metadata.Nature.SNAPSHOT.equals( nature );
        boolean snapshots = !Metadata.Nature.RELEASE.equals( nature );
        return remoteRepositoryManager.getPolicy( session, repository, releases, snapshots );
    }

    private void metadataResolving( RepositorySystemSession session, RequestTrace trace, Metadata metadata,
                                    ArtifactRepository repository )
    {
        RepositoryEvent.Builder event = new RepositoryEvent.Builder( session, EventType.METADATA_RESOLVING );
        event.setTrace( trace );
        event.setMetadata( metadata );
        event.setRepository( repository );

        repositoryEventDispatcher.dispatch( event.build() );
    }

    private void metadataResolved( RepositorySystemSession session, RequestTrace trace, Metadata metadata,
                                   ArtifactRepository repository, Exception exception )
    {
        RepositoryEvent.Builder event = new RepositoryEvent.Builder( session, EventType.METADATA_RESOLVED );
        event.setTrace( trace );
        event.setMetadata( metadata );
        event.setRepository( repository );
        event.setException( exception );
        event.setFile( metadata.getFile() );

        repositoryEventDispatcher.dispatch( event.build() );
    }

    private void metadataDownloading( RepositorySystemSession session, RequestTrace trace, Metadata metadata,
                                      ArtifactRepository repository )
    {
        RepositoryEvent.Builder event = new RepositoryEvent.Builder( session, EventType.METADATA_DOWNLOADING );
        event.setTrace( trace );
        event.setMetadata( metadata );
        event.setRepository( repository );

        repositoryEventDispatcher.dispatch( event.build() );
    }

    private void metadataDownloaded( RepositorySystemSession session, RequestTrace trace, Metadata metadata,
                                     ArtifactRepository repository, File file, Exception exception )
    {
        RepositoryEvent.Builder event = new RepositoryEvent.Builder( session, EventType.METADATA_DOWNLOADED );
        event.setTrace( trace );
        event.setMetadata( metadata );
        event.setRepository( repository );
        event.setException( exception );
        event.setFile( file );

        repositoryEventDispatcher.dispatch( event.build() );
    }

    private Executor getExecutor( int threads )
    {
        if ( threads <= 1 )
        {
            return new Executor()
            {
                public void execute( Runnable command )
                {
                    command.run();
                }
            };
        }
        else
        {
            return new ThreadPoolExecutor( threads, threads, 3, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(),
                                           new WorkerThreadFactory( null ) );
        }
    }

    private void shutdown( Executor executor )
    {
        if ( executor instanceof ExecutorService )
        {
            ( (ExecutorService) executor ).shutdown();
        }
    }

    class ResolveTask
        implements Runnable
    {

        final RepositorySystemSession session;

        final RequestTrace trace;

        final MetadataResult result;

        final MetadataRequest request;

        final File metadataFile;

        final String policy;

        final List<UpdateCheck<Metadata, MetadataTransferException>> checks;

        volatile MetadataTransferException exception;

        ResolveTask( RepositorySystemSession session, RequestTrace trace, MetadataResult result,
                            File metadataFile, List<UpdateCheck<Metadata, MetadataTransferException>> checks,
                            String policy )
        {
            this.session = session;
            this.trace = trace;
            this.result = result;
            this.request = result.getRequest();
            this.metadataFile = metadataFile;
            this.policy = policy;
            this.checks = checks;
        }

        public void run()
        {
            Metadata metadata = request.getMetadata();
            RemoteRepository requestRepository = request.getRepository();

            metadataDownloading( session, trace, metadata, requestRepository );

            try
            {
                List<RemoteRepository> repositories = new ArrayList<>();
                for ( UpdateCheck<Metadata, MetadataTransferException> check : checks )
                {
                    repositories.add( check.getAuthoritativeRepository() );
                }

                MetadataDownload download = new MetadataDownload();
                download.setMetadata( metadata );
                download.setRequestContext( request.getRequestContext() );
                download.setFile( metadataFile );
                download.setChecksumPolicy( policy );
                download.setRepositories( repositories );
                download.setListener( SafeTransferListener.wrap( session ) );
                download.setTrace( trace );

                try ( RepositoryConnector connector =
                              repositoryConnectorProvider.newRepositoryConnector( session, requestRepository ) )
                {
                    connector.get( null, Arrays.asList( download ) );
                }

                exception = download.getException();

                if ( exception == null )
                {

                    List<String> contexts = Collections.singletonList( request.getRequestContext() );
                    LocalMetadataRegistration registration =
                        new LocalMetadataRegistration( metadata, requestRepository, contexts );

                    session.getLocalRepositoryManager().add( session, registration );
                }
                else if ( request.isDeleteLocalCopyIfMissing() && exception instanceof MetadataNotFoundException )
                {
                    download.getFile().delete();
                }
            }
            catch ( NoRepositoryConnectorException e )
            {
                exception = new MetadataTransferException( metadata, requestRepository, e );
            }

            /*
             * NOTE: Touch after registration with local repo to ensure concurrent resolution is not rejected with
             * "already updated" via session data when actual update to local repo is still pending.
             */
            for ( UpdateCheck<Metadata, MetadataTransferException> check : checks )
            {
                updateCheckManager.touchMetadata( session, check.setException( exception ) );
            }

            metadataDownloaded( session, trace, metadata, requestRepository, metadataFile, exception );
        }

    }

}