package org.sonatype.aether.connector.async;

/*******************************************************************************
 * Copyright (c) 2010-2011 Sonatype, Inc.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 *   http://www.eclipse.org/legal/epl-v10.html
 *******************************************************************************/

import com.ning.http.client.AsyncHttpClient;
import com.ning.http.client.AsyncHttpClientConfig;
import com.ning.http.client.AsyncHttpProvider;
import com.ning.http.client.FluentCaseInsensitiveStringsMap;
import com.ning.http.client.HttpResponseBodyPart;
import com.ning.http.client.HttpResponseHeaders;
import com.ning.http.client.ProxyServer;
import com.ning.http.client.ProxyServer.Protocol;
import com.ning.http.client.Realm;
import com.ning.http.client.Request;
import com.ning.http.client.RequestBuilder;
import com.ning.http.client.Response;
import com.ning.http.client.providers.netty.NettyAsyncHttpProvider;
import org.sonatype.aether.ConfigurationProperties;
import org.sonatype.aether.RepositoryCache;
import org.sonatype.aether.RepositorySystemSession;
import org.sonatype.aether.repository.Authentication;
import org.sonatype.aether.repository.Proxy;
import org.sonatype.aether.repository.RemoteRepository;
import org.sonatype.aether.repository.RepositoryPolicy;
import org.sonatype.aether.spi.connector.ArtifactDownload;
import org.sonatype.aether.spi.connector.ArtifactTransfer;
import org.sonatype.aether.spi.connector.ArtifactUpload;
import org.sonatype.aether.spi.connector.MetadataDownload;
import org.sonatype.aether.spi.connector.MetadataTransfer;
import org.sonatype.aether.spi.connector.MetadataUpload;
import org.sonatype.aether.spi.connector.RepositoryConnector;
import org.sonatype.aether.spi.connector.Transfer;
import org.sonatype.aether.spi.io.FileProcessor;
import org.sonatype.aether.spi.log.Logger;
import org.sonatype.aether.transfer.ArtifactNotFoundException;
import org.sonatype.aether.transfer.ArtifactTransferException;
import org.sonatype.aether.transfer.ChecksumFailureException;
import org.sonatype.aether.transfer.MetadataNotFoundException;
import org.sonatype.aether.transfer.MetadataTransferException;
import org.sonatype.aether.transfer.NoRepositoryConnectorException;
import org.sonatype.aether.transfer.TransferEvent;
import org.sonatype.aether.transfer.TransferEvent.EventType;
import org.sonatype.aether.transfer.TransferEvent.RequestType;
import org.sonatype.aether.transfer.TransferListener;
import org.sonatype.aether.transfer.TransferResource;
import org.sonatype.aether.util.ChecksumUtils;
import org.sonatype.aether.util.ConfigUtils;
import org.sonatype.aether.util.StringUtils;
import org.sonatype.aether.util.layout.MavenDefaultLayout;
import org.sonatype.aether.util.layout.RepositoryLayout;
import org.sonatype.aether.util.listener.DefaultTransferEvent;
import org.sonatype.aether.util.listener.DefaultTransferResource;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.RandomAccessFile;
import java.lang.reflect.InvocationTargetException;
import java.net.ConnectException;
import java.net.HttpURLConnection;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * A repository connector that uses the Async Http Client.
 *
 * @author Jeanfrancois Arcand
 */
class AsyncRepositoryConnector
    implements RepositoryConnector
{
    private final Logger logger;

    private final FileProcessor fileProcessor;

    private final RemoteRepository repository;

    private final AsyncHttpClient httpClient;

    private final Map<String, String> checksumAlgos;

    private final AtomicBoolean closed = new AtomicBoolean( false );

    private final RepositoryLayout layout = new MavenDefaultLayout();

    private final TransferListener listener;

    private final RepositorySystemSession session;

    private boolean useCache = false;

    private final boolean disableResumeSupport;

    private final static ConcurrentHashMap<RandomAccessFile, Boolean> activeDownloadFiles =
        new ConcurrentHashMap<RandomAccessFile, Boolean>();

    private final int maxIOExceptionRetry;

    private final FluentCaseInsensitiveStringsMap headers;

    /**
     * Create an {@link org.sonatype.aether.connector.async.AsyncRepositoryConnector} instance which connect to the
     * {@link RemoteRepository}
     *
     * @param repository the remote repository
     * @param session    the {@link RepositorySystemSession}
     * @param logger     the logger.
     * @throws NoRepositoryConnectorException
     */
    public AsyncRepositoryConnector( RemoteRepository repository, RepositorySystemSession session,
                                     FileProcessor fileProcessor, Logger logger )
        throws NoRepositoryConnectorException
    {
        this.logger = logger;
        this.repository = repository;
        this.listener = session.getTransferListener();
        this.fileProcessor = fileProcessor;
        this.session = session;

        if ( !"default".equals( repository.getContentType() ) )
        {
            throw new NoRepositoryConnectorException( repository );
        }

        if ( !repository.getProtocol().regionMatches( true, 0, "http", 0, "http".length() ) &&
            !repository.getProtocol().regionMatches( true, 0, "dav", 0, "dav".length() ) )
        {
            throw new NoRepositoryConnectorException( repository );
        }

        AsyncHttpClientConfig config = createConfig( session, repository, true );

        httpClient = new AsyncHttpClient( getProvider( session, config ), config );

        checksumAlgos = new LinkedHashMap<String, String>();
        checksumAlgos.put( "SHA-1", ".sha1" );
        checksumAlgos.put( "MD5", ".md5" );

        disableResumeSupport = ConfigUtils.getBoolean( session, false, "aether.connector.ahc.disableResumable" );
        maxIOExceptionRetry = ConfigUtils.getInteger( session, 3, "aether.connector.ahc.resumeRetry" );

        this.headers = new FluentCaseInsensitiveStringsMap();
        Map<?, ?> headers =
            ConfigUtils.getMap( session, null, ConfigurationProperties.HTTP_HEADERS + "." + repository.getId(),
                                ConfigurationProperties.HTTP_HEADERS );
        if ( headers != null )
        {
            for ( Map.Entry<?, ?> entry : headers.entrySet() )
            {
                if ( entry.getKey() instanceof String && entry.getValue() instanceof String )
                {
                    this.headers.add( entry.getKey().toString(), entry.getValue().toString() );
                }
            }
        }
    }

    private AsyncHttpProvider getProvider( RepositorySystemSession session, AsyncHttpClientConfig config )
    {
        String className = ConfigUtils.getString( session, "", "aether.connector.ahc.provider" );

        if ( className != null && className.length() > 0 )
        {
            if ( "netty".equals( className ) )
            {
                className = "com.ning.http.client.providers.netty.NettyAsyncHttpProvider";
            }
            else if ( "jdk".equals( className ) )
            {
                className = "com.ning.http.client.providers.jdk.JDKAsyncHttpProvider";
            }
            else if ( "apache".equals( className ) )
            {
                className = "com.ning.http.client.providers.apache.ApacheAsyncHttpProvider";
            }

            RepositoryCache cache = session.getCache();

            try
            {
                if ( cache == null || cache.get( session, className ) == null )
                {
                    logger.debug( "Using AHC provider " + className );

                    Class<?> providerClass = getClass().getClassLoader().loadClass( className );

                    Object inst = providerClass.getConstructor( AsyncHttpClientConfig.class ).newInstance( config );

                    return (AsyncHttpProvider) inst;
                }
            }
            catch ( LinkageError e )
            {
                warn( "Could not load AHC provider " + className + ", falling back to default", e );
            }
            catch ( ClassNotFoundException e )
            {
                logger.warn( "Could not load AHC provider " + className + ", falling back to default" );
            }
            catch ( ClassCastException e )
            {
                logger.warn( "Could not load type-compatible AHC provider " + className + ", falling back to default" );
            }
            catch ( Exception e )
            {
                Throwable cause = e;
                if ( e instanceof InvocationTargetException && e.getCause() != null )
                {
                    cause = e.getCause();
                }
                warn( "Could not instantiate AHC provider " + className + ", falling back to default", cause );
            }

            if ( cache != null )
            {
                cache.put( session, className, Boolean.TRUE );
            }
        }

        return getDefaultProvider( config );
    }

    private AsyncHttpProvider getDefaultProvider( AsyncHttpClientConfig config )
    {
        return new NettyAsyncHttpProvider( config );
    }

    private void warn( String message, Throwable cause )
    {
        String msg = message;
        if ( cause != null )
        {
            msg += ": " + cause;
        }
        if ( logger.isDebugEnabled() )
        {
            logger.warn( msg, cause );
        }
        else
        {
            logger.warn( msg );
        }
    }

    private Realm getRealm( RemoteRepository repository, String credentialEncoding )
    {
        Realm realm = null;

        Authentication a = repository.getAuthentication();
        if ( a != null && a.getUsername() != null )
        {
            realm = new Realm.RealmBuilder().setPrincipal( a.getUsername() ).setPassword(
                a.getPassword() ).setUsePreemptiveAuth( false ).setEnconding( credentialEncoding ).build();
        }

        return realm;
    }

    private ProxyServer getProxy( RemoteRepository repository, String credentialEncoding )
    {
        ProxyServer proxyServer = null;

        Proxy p = repository.getProxy();
        if ( p != null )
        {
            Authentication a = p.getAuthentication();
            boolean useSSL = repository.getProtocol().equalsIgnoreCase( "https" ) ||
                repository.getProtocol().equalsIgnoreCase( "dav:https" );
            if ( a == null )
            {
                proxyServer = new ProxyServer( useSSL ? Protocol.HTTPS : Protocol.HTTP, p.getHost(), p.getPort() );
            }
            else
            {
                proxyServer =
                    new ProxyServer( useSSL ? Protocol.HTTPS : Protocol.HTTP, p.getHost(), p.getPort(), a.getUsername(),
                                     a.getPassword() );
                proxyServer.setEncoding( credentialEncoding );
            }
        }

        return proxyServer;
    }

    /**
     * Create an {@link AsyncHttpClientConfig} instance based on the values from {@link RepositorySystemSession}
     *
     * @param session {link RepositorySystemSession}
     * @return a configured instance of
     */
    private AsyncHttpClientConfig createConfig( RepositorySystemSession session, RemoteRepository repository,
                                                boolean useCompression )
    {
        AsyncHttpClientConfig.Builder configBuilder = new AsyncHttpClientConfig.Builder();

        String userAgent =
            ConfigUtils.getString( session, ConfigurationProperties.DEFAULT_USER_AGENT, ConfigurationProperties.USER_AGENT );
        if ( !StringUtils.isEmpty( userAgent ) )
        {
            configBuilder.setUserAgent( userAgent );
        }
        int connectTimeout =
            ConfigUtils.getInteger( session, ConfigurationProperties.DEFAULT_CONNECT_TIMEOUT,
                             ConfigurationProperties.CONNECT_TIMEOUT );
        String credentialEncoding =
            ConfigUtils.getString( session, ConfigurationProperties.DEFAULT_HTTP_CREDENTIAL_ENCODING,
                                   ConfigurationProperties.HTTP_CREDENTIAL_ENCODING + "." + repository.getId(),
                                   ConfigurationProperties.HTTP_CREDENTIAL_ENCODING );

        configBuilder.setConnectionTimeoutInMs( connectTimeout );
        configBuilder.setCompressionEnabled( useCompression );
        configBuilder.setFollowRedirects( true );
        configBuilder.setMaxRequestRetry( 0 );
        configBuilder.setRequestTimeoutInMs( ConfigUtils.getInteger( session, ConfigurationProperties.DEFAULT_REQUEST_TIMEOUT,
                                                              ConfigurationProperties.REQUEST_TIMEOUT ) );

        configBuilder.setProxyServer( getProxy( repository, credentialEncoding ) );
        configBuilder.setRealm( getRealm( repository, credentialEncoding ) );

        return configBuilder.build();
    }

    private void await( CountDownLatch latch )
    {
        boolean interrupted = false;
        while ( latch.getCount() > 0 )
        {
            try
            {
                latch.await();
            }
            catch ( InterruptedException e )
            {
                interrupted = true;
            }
        }
        if ( interrupted )
        {
            Thread.currentThread().interrupt();
        }
    }

    /**
     * Use the async http client library to download artifacts and metadata.
     *
     * @param artifactDownloads The artifact downloads to perform, may be {@code null} or empty.
     * @param metadataDownloads The metadata downloads to perform, may be {@code null} or empty.
     */
    public void get( Collection<? extends ArtifactDownload> artifactDownloads,
                     Collection<? extends MetadataDownload> metadataDownloads )
    {
        if ( closed.get() )
        {
            throw new IllegalStateException( "connector closed" );
        }

        artifactDownloads = safe( artifactDownloads );
        metadataDownloads = safe( metadataDownloads );

        CountDownLatch latch = new CountDownLatch( artifactDownloads.size() + metadataDownloads.size() );

        Collection<GetTask<?>> tasks = new ArrayList<GetTask<?>>();

        for ( MetadataDownload download : metadataDownloads )
        {
            String resource = layout.getPath( download.getMetadata() ).getPath();
            GetTask<?> task =
                new GetTask<MetadataTransfer>( resource, download.getFile(), download.getChecksumPolicy(), latch,
                                               download, METADATA, false );
            tasks.add( task );
            task.run();
        }

        for ( ArtifactDownload download : artifactDownloads )
        {
            String resource = layout.getPath( download.getArtifact() ).getPath();
            GetTask<?> task =
                new GetTask<ArtifactTransfer>( resource, download.isExistenceCheck() ? null : download.getFile(),
                                               download.getChecksumPolicy(), latch, download, ARTIFACT, true );
            tasks.add( task );
            task.run();
        }

        await( latch );

        for ( GetTask<?> task : tasks )
        {
            task.flush();
        }
    }

    /**
     * Use the async http client library to upload artifacts and metadata.
     *
     * @param artifactUploads The artifact uploads to perform, may be {@code null} or empty.
     * @param metadataUploads The metadata uploads to perform, may be {@code null} or empty.
     */
    public void put( Collection<? extends ArtifactUpload> artifactUploads,
                     Collection<? extends MetadataUpload> metadataUploads )
    {
        if ( closed.get() )
        {
            throw new IllegalStateException( "connector closed" );
        }

        artifactUploads = safe( artifactUploads );
        metadataUploads = safe( metadataUploads );

        CountDownLatch latch = new CountDownLatch( artifactUploads.size() + metadataUploads.size() );

        Collection<PutTask<?>> tasks = new ArrayList<PutTask<?>>();

        for ( ArtifactUpload upload : artifactUploads )
        {
            String path = layout.getPath( upload.getArtifact() ).getPath();

            PutTask<?> task = new PutTask<ArtifactTransfer>( path, upload.getFile(), latch, upload, ARTIFACT );
            tasks.add( task );
            task.run();
        }

        for ( MetadataUpload upload : metadataUploads )
        {
            String path = layout.getPath( upload.getMetadata() ).getPath();

            PutTask<?> task = new PutTask<MetadataTransfer>( path, upload.getFile(), latch, upload, METADATA );
            tasks.add( task );
            task.run();
        }

        await( latch );

        for ( PutTask<?> task : tasks )
        {
            task.flush();
        }
    }

    private void handleResponseCode( String url, int responseCode, String responseMsg )
        throws AuthorizationException, ResourceDoesNotExistException, TransferException
    {
        if ( responseCode == HttpURLConnection.HTTP_NOT_FOUND )
        {
            throw new ResourceDoesNotExistException(
                String.format( "Unable to locate resource %s. Error code %s", url, responseCode ) );
        }

        if ( responseCode == HttpURLConnection.HTTP_FORBIDDEN || responseCode == HttpURLConnection.HTTP_UNAUTHORIZED ||
            responseCode == HttpURLConnection.HTTP_PROXY_AUTH )
        {
            throw new AuthorizationException(
                String.format( "Access denied to %s. Error code %s, %s", url, responseCode, responseMsg ) );
        }

        if ( responseCode >= HttpURLConnection.HTTP_MULT_CHOICE )
        {
            throw new TransferException(
                String.format( "Failed to transfer %s. Error code %s, %s", url, responseCode, responseMsg ) );
        }
    }

    private TransferEvent newEvent( TransferResource resource, Exception e, TransferEvent.RequestType requestType,
                                    TransferEvent.EventType eventType )
    {
        DefaultTransferEvent event = new DefaultTransferEvent();
        event.setResource( resource );
        event.setRequestType( requestType );
        event.setType( eventType );
        event.setException( e );
        return event;
    }

    class GetTask<T extends Transfer>
        implements Runnable
    {

        private final T download;

        private final String path;

        private final File file;

        private final String checksumPolicy;

        private final LatchGuard latch;

        private volatile Exception exception;

        private final ExceptionWrapper<T> wrapper;

        private final AtomicBoolean deleteFile = new AtomicBoolean( true );

        private final boolean allowResumable;

        public GetTask( String path, File file, String checksumPolicy, CountDownLatch latch, T download,
                        ExceptionWrapper<T> wrapper, boolean allowResumable )
        {
            this.path = path;
            this.file = file;
            this.checksumPolicy = checksumPolicy;
            this.allowResumable = allowResumable;
            this.latch = new LatchGuard( latch );
            this.download = download;
            this.wrapper = wrapper;
        }

        public T getDownload()
        {
            return download;
        }

        public Exception getException()
        {
            return exception;
        }

        public void run()
        {
            download.setState( Transfer.State.ACTIVE );
            final String uri = validateUri( path );
            final DefaultTransferResource transferResource =
                new DefaultTransferResource( repository.getUrl(), path, file, download.getTrace() );
            final boolean ignoreChecksum = RepositoryPolicy.CHECKSUM_POLICY_IGNORE.equals( checksumPolicy );
            CompletionHandler completionHandler = null;

            final FileLockCompanion fileLockCompanion = ( file != null )
                ? createOrGetTmpFile( file.getPath(), allowResumable )
                : new FileLockCompanion( null, null );

            try
            {
                long length = 0;
                if ( fileLockCompanion.getFile() != null )
                {
                    fileProcessor.mkdirs( fileLockCompanion.getFile().getParentFile() );
                }

                // Position the file to the end in case we are resuming an aborded download.
                final RandomAccessFile resumableFile = fileLockCompanion.getFile() == null
                    ? null
                    : new RandomAccessFile( fileLockCompanion.getFile(), "rw" );
                if ( resumableFile != null )
                {
                    length = resumableFile.length();
                }

                FluentCaseInsensitiveStringsMap headers = new FluentCaseInsensitiveStringsMap();
                if ( !useCache )
                {
                    headers.add( "Pragma", "no-cache" );
                }
                headers.add( "Accept", "text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2" );
                headers.replaceAll( AsyncRepositoryConnector.this.headers );

                Request request = null;
                final AtomicInteger maxRequestTry = new AtomicInteger();
                AsyncHttpClient client = httpClient;
                final AtomicBoolean closeOnComplete = new AtomicBoolean( false );

                /**
                 * If length > 0, it means we are resuming a interrupted download. If that's the case,
                 * we can't re-use the current httpClient because compression is enabled, and supporting
                 * compression per request is not supported in ahc and may never has it could have
                 * a performance impact.
                 */
                if ( length > 0 )
                {
                    AsyncHttpClientConfig config = createConfig( session, repository, false );
                    client = new AsyncHttpClient( new NettyAsyncHttpProvider( config ) );
                    request = client.prepareGet( uri ).setRangeOffset( length ).setHeaders( headers ).build();
                    closeOnComplete.set( true );
                }
                else
                {
                    request = httpClient.prepareGet( uri ).setHeaders( headers ).build();
                }

                final Request activeRequest = request;
                final AsyncHttpClient activeHttpClient = client;
                completionHandler = new CompletionHandler( transferResource, httpClient, logger, RequestType.GET )
                {
                    private final AtomicBoolean handleTmpFile = new AtomicBoolean( true );

                    private final AtomicBoolean localException = new AtomicBoolean ( false );

                    /**
                     * {@inheritDoc}
                     */
                    @Override
                    public STATE onHeadersReceived( final HttpResponseHeaders headers )
                        throws Exception
                    {
                        FluentCaseInsensitiveStringsMap h = headers.getHeaders();
                        String rangeByteValue = h.getFirstValue( "Content-Range" );
                        // Make sure the server acceptance of the range requests headers
                        if ( rangeByteValue != null && rangeByteValue.compareToIgnoreCase( "none" ) != 0 )
                        {
                            resumableFile.seek( resumableFile.length() );
                        }
                        else
                        {
                            resumableFile.seek( 0 );
                        }
                        return super.onHeadersReceived( headers );
                    }

                    @Override
                    public void onThrowable( Throwable t )
                    {
                        boolean resume = false;
                        try
                        {
                            logger.debug("onThrowable", t);

                            /**
                             * If an IOException occurs, let's try to resume the request based on how much bytes has
                             * been so far downloaded. Fail after IOException.
                             */
                            try
                            {
                                if ( !disableResumeSupport && !localException.get()
                                    && maxRequestTry.get() < maxIOExceptionRetry && isResumeWorthy( t ) )
                                {
                                    logger.debug( "Trying to recover from an IOException " + activeRequest );
                                    maxRequestTry.incrementAndGet();
                                    Request newRequest =
                                        new RequestBuilder( activeRequest ).setRangeOffset( resumableFile.length() ).build();
                                    activeHttpClient.executeRequest( newRequest, this );
                                    resume = true;
                                    return;
                                }
                            }
                            catch ( Throwable rt )
                            {
                                logger.warn( "Could not resume download", rt );
                            }

                            localException.set( false );

                            if ( Exception.class.isAssignableFrom( t.getClass() ) )
                            {
                                exception = Exception.class.cast( t );
                            }
                            else
                            {
                                exception = new Exception( t );
                            }

                            if ( closeOnComplete.get() )
                            {
                                activeHttpClient.close();
                            }

                            super.onThrowable( t );

                            fireTransferFailed();
                        }
                        catch ( Throwable ex )
                        {
                            logger.warn( "Unexpected exception", ex );
                        }
                        finally
                        {
                            if ( resume )
                            {
                                return;
                            }
                            if ( resumableFile != null )
                            {
                                try
                                {
                                    resumableFile.close();
                                }
                                catch ( IOException ex )
                                {
                                }
                            }
                            deleteFile( fileLockCompanion );

                            latch.countDown();
                            removeListeners();
                        }
                    }

                    private void removeListeners()
                    {
                        removeTransferListener( listener );
                    }

                    public STATE onBodyPartReceived( final HttpResponseBodyPart content )
                        throws Exception
                    {
                        if ( status() != null &&
                            ( status().getStatusCode() == 200 || status().getStatusCode() == 206 ) )
                        {
                            byte[] bytes = content.getBodyPartBytes();
                            try
                            {
                                resumableFile.write( bytes );
                            }
                            catch ( IOException ex )
                            {
                                logger.debug("onBodyPartReceived", ex);
                                exception = ex;
                                localException.set(true);
                                throw ex;
                            }
                        }
                        return super.onBodyPartReceived( content );
                    }

                    @Override
                    public Response onCompleted( Response r )
                        throws Exception
                    {
                        try
                        {
                            deleteFile.set( true );
                            try
                            {
                                resumableFile.close();
                            }
                            catch ( IOException ex )
                            {
                            }

                            final Response response = super.onCompleted( r );

                            handleResponseCode( uri, response.getStatusCode(), response.getStatusText() );

                            if ( !ignoreChecksum )
                            {
                                activeHttpClient.getConfig().executorService().execute( new Runnable()
                                {
                                    public void run()
                                    {
                                        try
                                        {
                                            try
                                            {
                                                Map<String, Object> checksums =
                                                    ChecksumUtils.calc( fileLockCompanion.getFile(),
                                                                        checksumAlgos.keySet() );
                                                if ( !verifyChecksum( file, uri, (String) checksums.get( "SHA-1" ),
                                                                      ".sha1" ) &&
                                                    !verifyChecksum( file, uri, (String) checksums.get( "MD5" ),
                                                                     ".md5" ) )
                                                {
                                                    throw new ChecksumFailureException( "Checksum validation failed" +
                                                                                            ", no checksums available from the repository" );
                                                }
                                            }
                                            catch ( ChecksumFailureException e )
                                            {
                                                if ( RepositoryPolicy.CHECKSUM_POLICY_FAIL.equals( checksumPolicy ) )
                                                {
                                                    throw e;
                                                }
                                                if ( listener != null )
                                                {
                                                    listener.transferCorrupted(
                                                        newEvent( transferResource, e, RequestType.GET,
                                                                  EventType.CORRUPTED ) );
                                                }
                                            }
                                        }
                                        catch ( Exception ex )
                                        {
                                            exception = ex;
                                        }
                                        finally
                                        {
                                            if ( exception == null )
                                            {
                                                try
                                                {
                                                    rename( fileLockCompanion.getFile(), file );
                                                    releaseLock( fileLockCompanion );
                                                }
                                                catch ( IOException e )
                                                {
                                                    exception = e;
                                                }
                                            }
                                            else
                                            {
                                                deleteFile( fileLockCompanion );
                                            }

                                            latch.countDown();
                                            if ( closeOnComplete.get() )
                                            {
                                                activeHttpClient.close();
                                            }
                                        }
                                    }
                                } );
                            }
                            else
                            {

                                rename( fileLockCompanion.getFile(), file );
                                releaseLock( fileLockCompanion );
                                handleTmpFile.set( false );

                                // asyncHttpClient.close may takes time before all connections get closed.
                                // We unlatch first.
                                latch.countDown();
                                if ( closeOnComplete.get() )
                                {
                                    activeHttpClient.close();
                                }
                            }
                            removeListeners();

                            return response;
                        }
                        catch ( Exception ex )
                        {
                            exception = ex;
                            localException.set(true);
                            throw ex;
                        }
                        finally
                        {
                            try
                            {
                                if ( handleTmpFile.get() && fileLockCompanion.getFile() != null )
                                {
                                    if ( exception != null )
                                    {
                                        deleteFile( fileLockCompanion );
                                    }
                                    else if ( ignoreChecksum )
                                    {
                                        rename( fileLockCompanion.getFile(), file );
                                        releaseLock( fileLockCompanion );
                                    }
                                }
                            }
                            catch ( IOException ex )
                            {
                                exception = ex;
                            }
                        }
                    }

                };

                try
                {
                    if ( file == null )
                    {
                        if ( !resourceExist( uri ) )
                        {
                            throw new ResourceDoesNotExistException(
                                "Could not find " + uri + " in " + repository.getUrl() );
                        }
                        latch.countDown();
                    }
                    else
                    {
                        if ( listener != null )
                        {
                            completionHandler.addTransferListener( listener );
                            listener.transferInitiated(
                                newEvent( transferResource, null, RequestType.GET, EventType.INITIATED ) );
                        }

                        activeHttpClient.executeRequest( request, completionHandler );
                    }
                }
                catch ( Exception ex )
                {
                    try
                    {
                        if ( resumableFile != null )
                        {
                            resumableFile.close();
                        }
                    }
                    catch ( IOException ex2 )
                    {
                    }
                    deleteFile( fileLockCompanion );
                    exception = ex;
                    latch.countDown();
                }
            }
            catch ( Throwable t )
            {
                deleteFile( fileLockCompanion );
                try
                {
                    if ( Exception.class.isAssignableFrom( t.getClass() ) )
                    {
                        exception = Exception.class.cast( t );
                    }
                    else
                    {
                        exception = new Exception( t );
                    }
                    if ( listener != null )
                    {
                        listener.transferFailed(
                            newEvent( transferResource, exception, RequestType.GET, EventType.FAILED ) );
                    }
                }
                finally
                {
                    latch.countDown();
                }
            }
        }

        private boolean isResumeWorthy( Throwable t )
        {
            if ( t instanceof IOException )
            {
                if ( t instanceof ConnectException )
                {
                    return false;
                }
                return true;
            }
            return false;
        }

        private void deleteFile( FileLockCompanion fileLockCompanion )
        {
            if ( fileLockCompanion.getFile() != null && deleteFile.get() )
            {
                releaseLock( fileLockCompanion );
                activeDownloadFiles.remove( fileLockCompanion.getFile() );
                fileLockCompanion.getFile().delete();
            }
        }

        private boolean verifyChecksum( File file, String path, String actual, String ext )
            throws ChecksumFailureException
        {
            File tmp = getTmpFile( file.getPath() + ext );
            try
            {
                try
                {
                    Response response = httpClient.prepareGet( path + ext ).setHeaders( headers ).execute().get();

                    if ( response.getStatusCode() == HttpURLConnection.HTTP_NOT_FOUND )
                    {
                        return false;
                    }

                    OutputStream fs = new BufferedOutputStream( new FileOutputStream( tmp ) );
                    try
                    {
                        InputStream is = response.getResponseBodyAsStream();
                        final byte[] buffer = new byte[4 * 1024];
                        int n = 0;
                        while ( -1 != ( n = is.read( buffer ) ) )
                        {
                            fs.write( buffer, 0, n );
                        }
                        fs.flush();
                    }
                    finally
                    {
                        fs.close();
                    }

                }
                catch ( Exception ex )
                {
                    throw new ChecksumFailureException( ex );
                }

                String expected;

                try
                {
                    expected = ChecksumUtils.read( tmp );
                }
                catch ( IOException e )
                {
                    throw new ChecksumFailureException( e );
                }

                if ( expected.equalsIgnoreCase( actual ) )
                {
                    try
                    {
                        rename( tmp, new File( file.getPath() + ext ) );
                    }
                    catch ( IOException e )
                    {
                        // ignored, non-critical
                    }
                }
                else
                {
                    throw new ChecksumFailureException( expected, actual );
                }
            }
            finally
            {
                tmp.delete();
            }

            return true;
        }

        public void flush()
        {
            wrapper.wrap( download, exception, repository );
            download.setState( Transfer.State.DONE );
        }

        private void rename( File from, File to )
            throws IOException
        {
            fileProcessor.move( from, to );
        }
    }

    class PutTask<T extends Transfer>
        implements Runnable
    {

        private final T upload;

        private final ExceptionWrapper<T> wrapper;

        private final String path;

        private final File file;

        private volatile Exception exception;

        private final LatchGuard latch;

        public PutTask( String path, File file, CountDownLatch latch, T upload, ExceptionWrapper<T> wrapper )
        {
            this.path = path;
            this.file = file;
            this.upload = upload;
            this.wrapper = wrapper;
            this.latch = new LatchGuard( latch );
        }

        public Exception getException()
        {
            return exception;
        }

        public void run()
        {
            upload.setState( Transfer.State.ACTIVE );
            final DefaultTransferResource transferResource =
                new DefaultTransferResource( repository.getUrl(), path, file, upload.getTrace() );

            try
            {
                final String uri = validateUri( path );

                final CompletionHandler completionHandler =
                    new CompletionHandler( transferResource, httpClient, logger, RequestType.PUT )
                    {
                        @Override
                        public void onThrowable( Throwable t )
                        {
                            try
                            {
                                super.onThrowable( t );
                                if ( Exception.class.isAssignableFrom( t.getClass() ) )
                                {
                                    exception = Exception.class.cast( t );
                                }
                                else
                                {
                                    exception = new Exception( t );
                                }

                                if ( listener != null )
                                {
                                    listener.transferFailed(
                                        newEvent( transferResource, exception, RequestType.PUT, EventType.FAILED ) );
                                }
                            }
                            finally
                            {
                                latch.countDown();
                            }
                        }

                        @Override
                        public Response onCompleted( Response r )
                            throws Exception
                        {
                            try
                            {
                                Response response = super.onCompleted( r );
                                handleResponseCode( uri, response.getStatusCode(), response.getStatusText() );

                                httpClient.getConfig().executorService().execute( new Runnable()
                                {
                                    public void run()
                                    {
                                        try
                                        {
                                            uploadChecksums( file, uri );
                                        }
                                        catch ( Exception ex )
                                        {
                                            exception = ex;
                                        }
                                        finally
                                        {
                                            latch.countDown();
                                        }
                                    }
                                } );

                                return r;
                            }

                            catch ( Exception ex )
                            {
                                exception = ex;
                                throw ex;
                            }

                        }
                    };

                if ( listener != null )

                {
                    completionHandler.addTransferListener( listener );
                    listener.transferInitiated(
                        newEvent( transferResource, null, RequestType.PUT, EventType.INITIATED ) );
                }

                if ( file == null )
                {
                    throw new IllegalArgumentException( "no source file specified for upload" );
                }
                transferResource.setContentLength( file.length() );

                httpClient.preparePut( uri ).setHeaders( headers ).setBody(
                    new ProgressingFileBodyGenerator( file, completionHandler ) ).execute( completionHandler );
            }
            catch ( Exception e )
            {
                try
                {
                    if ( listener != null )
                    {
                        listener.transferFailed( newEvent( transferResource, e, RequestType.PUT, EventType.FAILED ) );
                    }
                    exception = e;
                }
                finally
                {
                    latch.countDown();
                }
            }
        }

        public void flush()
        {
            wrapper.wrap( upload, exception, repository );
            upload.setState( Transfer.State.DONE );
        }

        private void uploadChecksums( File file, String path )
        {
            try
            {
                Map<String, Object> checksums = ChecksumUtils.calc( file, checksumAlgos.keySet() );
                for ( Map.Entry<String, Object> entry : checksums.entrySet() )
                {
                    uploadChecksum( file, path, entry.getKey(), entry.getValue() );
                }
            }
            catch ( IOException e )
            {
                logger.debug( "Failed to upload checksums for " + file + ": " + e.getMessage(), e );
            }
        }

        private void uploadChecksum( File file, String path, String algo, Object checksum )
        {
            try
            {
                if ( checksum instanceof Exception )
                {
                    throw (Exception) checksum;
                }

                String ext = checksumAlgos.get( algo );

                // Here we go blocking as this is a simple request.
                Response response =
                    httpClient.preparePut( path + ext ).setHeaders( headers ).setBody( String.valueOf( checksum ) ).execute().get();

                if ( response == null || response.getStatusCode() >= HttpURLConnection.HTTP_BAD_REQUEST )
                {
                    throw new TransferException(
                        String.format( "Checksum failed for %s with status code %s", path + ext, response == null
                            ? HttpURLConnection.HTTP_INTERNAL_ERROR
                            : response.getStatusCode() ) );
                }
            }
            catch ( Exception e )
            {
                logger.debug( "Failed to upload " + algo + " checksum for " + file + ": " + e.getMessage(), e );
            }
        }

    }

    /**
     * Builds a complete URL string from the repository URL and the relative path passed.
     *
     * @param path the relative path
     * @return the complete URL
     */
    private String buildUrl( String path )
    {
        final String repoUrl = repository.getUrl();
        path = path.replace( ' ', '+' );

        if ( repoUrl.charAt( repoUrl.length() - 1 ) != '/' )
        {
            return repoUrl + '/' + path;
        }
        return repoUrl + path;
    }

    private String validateUri( String path )
    {
        String tmpUri = buildUrl( path );
        // If we get dav request here, switch to http as no need for dav method.
        String dav = "dav";
        String davHttp = "dav:http";
        if ( tmpUri.startsWith( dav ) )
        {
            if ( tmpUri.startsWith( davHttp ) )
            {
                tmpUri = tmpUri.substring( dav.length() + 1 );
            }
            else
            {
                tmpUri = "http" + tmpUri.substring( dav.length() );
            }
        }
        return tmpUri;
    }

    private boolean resourceExist( String url )
        throws IOException, ExecutionException, InterruptedException, TransferException, AuthorizationException
    {
        int statusCode = httpClient.prepareHead( url ).setHeaders( headers ).execute().get().getStatusCode();

        switch ( statusCode )
        {
            case HttpURLConnection.HTTP_OK:
                return true;

            case HttpURLConnection.HTTP_FORBIDDEN:
                throw new AuthorizationException(
                    String.format( "Access denied to %s . Status code %s", url, statusCode ) );

            case HttpURLConnection.HTTP_NOT_FOUND:
                return false;

            case HttpURLConnection.HTTP_UNAUTHORIZED:
                throw new AuthorizationException(
                    String.format( "Access denied to %s . Status code %s", url, statusCode ) );

            default:
                throw new TransferException(
                    "Failed to look for file: " + buildUrl( url ) + ". Return code is: " + statusCode );
        }
    }

    static interface ExceptionWrapper<T>
    {
        void wrap( T transfer, Exception e, RemoteRepository repository );
    }

    public void close()
    {
        closed.set( true );
        httpClient.close();
    }

    private <T> Collection<T> safe( Collection<T> items )
    {
        return ( items != null ) ? items : Collections.<T>emptyList();
    }

    /**
     * Create a {@link FileLockCompanion} containing a reference to a temporary {@link File} used when downloading
     * a remote file. If a local and incomplete version of a file is available, use that file and resume bytes downloading.
     * To prevent multiple process trying to resume the same file, a {@link FileLock} companion to the tmeporary file is
     * created and used to prevent concurrency issue.
     *
     * @param path           The downloaded path
     * @param allowResumable Allow resumable download, or not.
     * @return
     */
    private FileLockCompanion createOrGetTmpFile( String path, boolean allowResumable )
    {
        if ( !disableResumeSupport && allowResumable )
        {
            File f = new File( path );
            File parentFile = f.getParentFile();
            if ( parentFile.isDirectory() )
            {
                for ( File tmpFile : parentFile.listFiles( new FilenameFilter()
                {
                    public boolean accept( File dir, String name )
                    {
                        if ( name.indexOf( "." ) > 0 && name.lastIndexOf( "." ) == name.indexOf( ".ahc" ) )
                        {
                            return true;
                        }
                        return false;
                    }
                } ) )
                {

                    if ( tmpFile.length() > 0 )
                    {
                        String realPath = tmpFile.getPath().substring( 0, tmpFile.getPath().lastIndexOf( "." ) );

                        FileLockCompanion fileLockCompanion = null;
                        if ( realPath.equals( path ) )
                        {
                            File newFile = tmpFile;
                            synchronized ( activeDownloadFiles )
                            {
                                fileLockCompanion = lockFile( tmpFile );
                                logger.debug( String.format( "Found an incomplete download for file %s.", path ) );

                                if ( fileLockCompanion.getLock() == null )
                                {
                                    /**
                                     * Lock failed so we need to regenerate a new tmp file.
                                     */
                                    newFile = getTmpFile( path );
                                    fileLockCompanion = lockFile( newFile );

                                }
                                return fileLockCompanion;
                            }
                        }
                    }
                }
            }
        }
        return new FileLockCompanion( getTmpFile( path ), null );
    }

    /**
     * Simple placeholder for a File and it's associated lock.
     */
    private static class FileLockCompanion
    {

        private final File file;

        private final FileLock lock;

        private final String lockPathName;

        public FileLockCompanion( File file, FileLock lock )
        {
            this.file = file;
            this.lock = lock;
            this.lockPathName = null;
        }

        public FileLockCompanion( File file, FileLock lock, String lockPathName )
        {
            this.file = file;
            this.lock = lock;
            this.lockPathName = lockPathName;
        }

        public File getFile()
        {
            return file;
        }

        public FileLock getLock()
        {
            return lock;
        }

        public String getLockedPathFile()
        {
            return lockPathName;
        }

    }

    /**
     * Create a temporary file used to lock ({@link FileLock}) an associated incomplete file {@link File}. The {@link FileLock}'s name
     * is derived from the original file, appending ".lock" at the end. Usually this method gets executed when a
     * download fail to complete because the JVM goes down. In that case we resume the incomplete download and to prevent
     * multiple process to work on the same file, we use a dedicated {@link FileLock}.
     *
     * @param tmpFile a file on which we want to create a temporary lock file.
     * @return a {@link FileLockCompanion} contains the {@link File} and a {@link FileLock} if it was possible to lock the file.
     */
    private FileLockCompanion lockFile( File tmpFile )
    {
        try
        {
            // On Unix tmpLock.getChannel().tryLock may not fail inside the same process, so we must keep track
            // of current resumable file.
            if ( activeDownloadFiles.containsKey( tmpFile ) )
            {
                return new FileLockCompanion( tmpFile, null );
            }

            RandomAccessFile tmpLock = new RandomAccessFile( tmpFile.getPath() + ".lock", "rw" );
            FileLock lock = tmpLock.getChannel().tryLock( 0, 1, false );

            if ( lock != null )
            {
                activeDownloadFiles.put( tmpLock, Boolean.TRUE );
            }
            else if ( lock == null )
            {
                try
                {
                    tmpLock.close();
                }
                catch ( IOException ex )
                {

                }
            }

            return new FileLockCompanion( tmpFile, lock, tmpFile.getPath() + ".lock" );
        }
        catch ( OverlappingFileLockException ex )
        {
            return new FileLockCompanion( tmpFile, null );
        }
        catch ( IOException ex )
        {
            return new FileLockCompanion( tmpFile, null );
        }
    }

    private void releaseLock( FileLockCompanion fileLockCompanion )
    {
        try
        {
            if ( fileLockCompanion.getLock() != null )
            {
                try
                {
                    fileLockCompanion.getLock().channel().close();
                    fileLockCompanion.getLock().release();
                }
                finally
                {
                    if ( fileLockCompanion.getLockedPathFile() != null )
                    {
                        new File( fileLockCompanion.getLockedPathFile() ).delete();
                    }
                }
            }
        }
        catch ( IOException e )
        {
            // Ignore.
        }
    }

    private File getTmpFile( String path )
    {
        File file;
        do
        {
            file = new File( path + ".ahc" + UUID.randomUUID().toString().replace( "-", "" ).substring( 0, 16 ) );
        }
        while ( file.exists() );
        return file;
    }

    private static final ExceptionWrapper<MetadataTransfer> METADATA = new ExceptionWrapper<MetadataTransfer>()
    {
        public void wrap( MetadataTransfer transfer, Exception e, RemoteRepository repository )
        {
            MetadataTransferException ex = null;
            if ( e instanceof ResourceDoesNotExistException )
            {
                ex = new MetadataNotFoundException( transfer.getMetadata(), repository );
            }
            else if ( e != null )
            {
                ex = new MetadataTransferException( transfer.getMetadata(), repository, e );
            }
            transfer.setException( ex );
        }
    };

    private static final ExceptionWrapper<ArtifactTransfer> ARTIFACT = new ExceptionWrapper<ArtifactTransfer>()
    {
        public void wrap( ArtifactTransfer transfer, Exception e, RemoteRepository repository )
        {
            ArtifactTransferException ex = null;
            if ( e instanceof ResourceDoesNotExistException )
            {
                ex = new ArtifactNotFoundException( transfer.getArtifact(), repository );
            }
            else if ( e != null )
            {
                ex = new ArtifactTransferException( transfer.getArtifact(), repository, e );
            }
            transfer.setException( ex );
        }
    };

    private class LatchGuard
    {

        private final CountDownLatch latch;

        private final AtomicBoolean done = new AtomicBoolean( false );

        public LatchGuard( CountDownLatch latch )
        {
            this.latch = latch;
        }

        public void countDown()
        {
            if ( !done.getAndSet( true ) )
            {
                latch.countDown();
            }
        }
    }

}
