/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.implrepo.conf;

import org.apache.commons.lang.StringUtils;
import org.commonjava.indy.conf.IndyConfigInfo;
import org.commonjava.web.config.ConfigurationException;
import org.commonjava.web.config.annotation.SectionName;
import org.commonjava.web.config.section.MapSectionListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.enterprise.context.ApplicationScoped;
import java.io.File;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.regex.PatternSyntaxException;

@ApplicationScoped
@SectionName( ImpliedRepoConfig.SECTION_NAME)
public class ImpliedRepoConfig
        extends MapSectionListener
    implements IndyConfigInfo
{

    public static final String SECTION_NAME = "implied-repos";

    public static final String ENABLED_KEY = "enabled";

    public static final String ENABLE_GROUP_KEY = "enabled.group";

    public static final String INCLUDE_SNAPSHOTS_KEY = "include.snapshots";

    public static final String DISABLED_HOST_KEY = "disable";

    public static final boolean DEFAULT_INCLUDE_SNAPSHOT_REPOS = false;

    public static final boolean DEFAULT_ENABLED = false;

    private Boolean enabled;

    private List<String> enabledGroupNamePatterns;

    private Boolean includeSnapshotRepos;

    private List<String> blacklistedHosts = new ArrayList<>();

    public ImpliedRepoConfig()
    {
    }

    public boolean isEnabled()
    {
        return enabled == null ? DEFAULT_ENABLED : enabled;
    }

    public void setEnabled( boolean enabled )
    {
        this.enabled = enabled;
    }

    public List<String> getEnabledGroupNamePatterns()
    {
        return enabledGroupNamePatterns;
    }

    public void setEnabledGroupNamePatterns( List<String> enabledGroupNamePatterns )
    {
        this.enabledGroupNamePatterns = enabledGroupNamePatterns;
    }

    public boolean isIncludeSnapshotRepos()
    {
        return includeSnapshotRepos == null ? DEFAULT_INCLUDE_SNAPSHOT_REPOS : includeSnapshotRepos;
    }

    public void setIncludeSnapshotRepos( final Boolean includeSnapshotRepos )
    {
        this.includeSnapshotRepos = includeSnapshotRepos;
    }

    public void addBlacklistedHost( final String host )
    {
        this.blacklistedHosts.add( host );
    }

    public synchronized void addEnabledGroupNamePattern( String groupName )
    {
        if ( this.enabledGroupNamePatterns == null )
        {
            this.enabledGroupNamePatterns = new ArrayList<>();
        }

        this.enabledGroupNamePatterns.add( groupName );
    }

    public boolean isEnabledForGroup( String name )
    {
        if ( enabledGroupNamePatterns == null || StringUtils.isEmpty( name ) )
        {
            return false;
        }

        Optional<String> found =
                enabledGroupNamePatterns.stream().filter( ( pattern ) -> name.matches( pattern ) ).findFirst();

        return found.isPresent();
    }

    public boolean isBlacklisted( final URL url )
    {
        final String proto = url.getProtocol();
        final String host = url.getHost();
        int port = url.getPort();
        if ( port < 0 )
        {
            port = "https".equals( proto ) ? 443 : 80;
        }

        String hostAndPort = host + ":" + port;
        String hostAndPortAndProto = proto + "://" + hostAndPort;
        String hostAndProto = proto + "://" + host;
        String u = url.toString();

        if ( blacklistedHosts.contains( host ) || blacklistedHosts.contains( hostAndPort ) || blacklistedHosts.contains(
                hostAndPortAndProto ) || blacklistedHosts.contains( hostAndProto ) || blacklistedHosts.contains( u ) )
        {
            return true;
        }

        for ( String bl : blacklistedHosts )
        {
            try
            {
                if ( u.matches( bl ) )
                {
                    return true;
                }
            }
            catch ( PatternSyntaxException e )
            {
                Logger logger = LoggerFactory.getLogger( getClass() );
                logger.warn( "[BLACKLIST SKIP] Regex comparison failed on pattern: " + bl, e);
            }
        }

        return false;
    }

    public List<String> getBlacklistedHosts()
    {
        return blacklistedHosts;
    }

    public void setBlacklistedHosts( final List<String> blacklistedHosts )
    {
        this.blacklistedHosts = blacklistedHosts;
    }

    @Override
    public synchronized void parameter( final String name, final String value )
        throws ConfigurationException
    {
        switch ( name )
        {
            case ENABLED_KEY:
            {
                this.enabled = Boolean.parseBoolean( value );
                break;
            }
            case ENABLE_GROUP_KEY:
            {
                if ( enabledGroupNamePatterns == null )
                {
                    enabledGroupNamePatterns = new ArrayList<>();
                }

                this.enabledGroupNamePatterns.add( value );
                break;
            }
            case INCLUDE_SNAPSHOTS_KEY:
            {
                this.includeSnapshotRepos = Boolean.parseBoolean( value );
                break;
            }
            case DISABLED_HOST_KEY:
            {
                this.blacklistedHosts.add( value );
                break;
            }
            default:
            {
                throw new ConfigurationException(
                                                  "Invalid value: '{}' for parameter: '{}'. Only numeric values are accepted for section: '{}'.",
                                                  value, name, SECTION_NAME );
            }
        }
    }

//    @javax.enterprise.context.ApplicationScoped
//    public static class FeatureConfig
//        extends AbstractIndyFeatureConfig<ImpliedRepoConfig, ImpliedRepoConfig>
//    {
//        public FeatureConfig()
//        {
//            super( new ImpliedRepoConfig() );
//        }
//
//        @Produces
//        @Default
//        @ApplicationScoped
//        public ImpliedRepoConfig getImpliedRepoConfig()
//            throws ConfigurationException
//        {
//            return getPrefabInstance();
//        }
//
//        @Override
//        public IndyConfigClassInfo getInfo()
//        {
//            return getPrefabInstance();
//        }
//    }

    @Override
    public String getDefaultConfigFileName()
    {
        return new File( IndyConfigInfo.CONF_INCLUDES_DIR, "implied-repos.conf" ).getPath();
    }

    @Override
    public InputStream getDefaultConfig()
    {
        return Thread.currentThread()
                     .getContextClassLoader()
                     .getResourceAsStream( "default-implied-repos.conf" );
    }

    //    @Override
    //    public Class<?> getConfigurationClass()
    //    {
    //        return getClass();
    //    }

    public boolean isBlacklisted( final String url )
        throws MalformedURLException
    {
        final URL u = new URL( url );
        return isBlacklisted( u );
    }
}


/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.httprox.handler;

import org.apache.commons.codec.binary.Hex;
import org.apache.http.ConnectionClosedException;
import org.apache.http.HttpException;
import org.apache.http.HttpRequest;
import org.apache.http.HttpRequestFactory;
import org.apache.http.config.MessageConstraints;
import org.apache.http.impl.DefaultHttpRequestFactory;
import org.apache.http.impl.io.DefaultHttpRequestParser;
import org.apache.http.impl.io.HttpTransportMetricsImpl;
import org.apache.http.impl.io.SessionInputBufferImpl;
import org.apache.http.message.BasicLineParser;
import org.apache.http.message.LineParser;
import org.commonjava.indy.util.ApplicationHeader;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xnio.ChannelListener;
import org.xnio.conduits.ConduitStreamSinkChannel;
import org.xnio.conduits.ConduitStreamSourceChannel;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.io.PrintStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;

public final class ProxyRequestReader
        implements ChannelListener<ConduitStreamSourceChannel>
{
    private static final int AWAIT_READABLE_IN_MILLISECONDS = 100;

    private static final List<Character> HEAD_END = Collections.unmodifiableList(
            Arrays.asList( Character.valueOf( '\r' ), Character.valueOf( '\n' ), Character.valueOf( '\r' ),
                           Character.valueOf( '\n' ) ) );

    private final Logger logger = LoggerFactory.getLogger( getClass() );

    private ByteArrayOutputStream bReq;

    private PrintStream pReq;

    private boolean headDone = false;

    private final ProxyResponseWriter writer;

    private final ConduitStreamSinkChannel sinkChannel;

    private ProxySSLTunnel sslTunnel;

    private List<Character> lastFour = new ArrayList<>();

    public ProxyRequestReader( final ProxyResponseWriter writer, final ConduitStreamSinkChannel sinkChannel )
    {
        this.writer = writer;
        this.sinkChannel = sinkChannel;
    }

    // TODO: May need to tune this to preserve request body.
    // TODO: NONE of the request headers (except authorization) are passed through!
    @Override
    public void handleEvent( final ConduitStreamSourceChannel sourceChannel )
    {
        boolean sendResponse = false;
        try
        {
            final int read = doRead( sourceChannel );

            if ( read <= 0 )
            {
                logger.debug( "Reads: {} ", read );
                return;
            }

            byte[] bytes = bReq.toByteArray();

            if ( sslTunnel != null )
            {
                logger.debug( "Send to ssl tunnel, {}, bytes:\n\n {}\n", new String( bytes ), Hex.encodeHexString( bytes ) );
                directTo( sslTunnel );
                return;
            }

            logger.debug( "Request in progress is:\n\n{}", new String( bytes ) );

            if ( headDone )
            {
                logger.debug( "Request done. parsing." );
                MessageConstraints mc = MessageConstraints.DEFAULT;
                SessionInputBufferImpl inbuf = new SessionInputBufferImpl( new HttpTransportMetricsImpl(), 1024 );
                HttpRequestFactory requestFactory = new DefaultHttpRequestFactory();
                LineParser lp = new BasicLineParser();

                DefaultHttpRequestParser requestParser = new DefaultHttpRequestParser( inbuf, lp, requestFactory, mc );

                inbuf.bind( new ByteArrayInputStream( bytes ) );

                try
                {
                    logger.debug( "Passing parsed http request off to response writer." );
                    HttpRequest request = requestParser.parse();
                    logger.debug( "Request contains {} header: '{}'", ApplicationHeader.authorization.key(),
                                  request.getHeaders( ApplicationHeader.authorization.key() ) );

                    writer.setHttpRequest( request );
                    sendResponse = true;
                }
                catch ( ConnectionClosedException e )
                {
                    logger.warn("Client closed connection. Aborting proxy request.");
                    sendResponse = false;
                    sourceChannel.shutdownReads();
                }
                catch ( HttpException e )
                {
                    logger.error( "Failed to parse http request: " + e.getMessage(), e );
                    writer.setError( e );
                }
            }
            else
            {
                logger.debug( "Request not finished. Pausing until more reads are available." );
                sourceChannel.resumeReads();
            }
        }
        catch ( final IOException e )
        {
            writer.setError( e );
            sendResponse = true;
        }

        if ( sendResponse )
        {
            sinkChannel.resumeWrites();
        }
    }

    public void setProxySSLTunnel( ProxySSLTunnel sslTunnel )
    {
        this.sslTunnel = sslTunnel;
    }

    private void directTo( ProxySSLTunnel sslTunnel ) throws IOException
    {
        byte[] bytes = bReq.toByteArray();
        logger.trace( "Write client data to ssl tunnel, size: {}", bytes.length );
        sslTunnel.write( bytes );
    }

    private int doRead( final ConduitStreamSourceChannel channel )
            throws IOException
    {
        bReq = new ByteArrayOutputStream();
        pReq = new PrintStream( bReq );

        logger.debug( "Starting read: {}", channel );

        int total = 0;
        while ( true )
        {
            ByteBuffer buf = ByteBuffer.allocate( 1024 );
            channel.awaitReadable( AWAIT_READABLE_IN_MILLISECONDS, TimeUnit.MILLISECONDS );

            int read = channel.read( buf ); // return the number of bytes read, possibly zero, or -1

            logger.debug( "Read {} bytes", read );

            if ( read == -1 ) // return -1 if the channel has reached end-of-stream
            {
                if ( total == 0 ) // nothing read, return -1 to indicate the EOF
                {
                    return -1;
                }
                else
                {
                    return total;
                }
            }

            if ( read == 0 ) // no new bytes this time
            {
                return total;
            }

            total += read;

            buf.flip();
            byte[] bbuf = new byte[buf.limit()];
            buf.get( bbuf );

            if ( !headDone )
            {
                // allows us to stop after header read...
                final String part = new String( bbuf );
                for ( final char c : part.toCharArray() )
                {
                    switch ( c )
                    {
                        case '\n':
                        {
                            while ( lastFour.size() > 3 )
                            {
                                lastFour.remove(0);
                            }

                            lastFour.add( c );
                            try
                            {
                                if ( bReq.size() > 0 && HEAD_END.equals( lastFour ) )
                                {
                                    logger.debug( "Detected end of request headers." );
                                    headDone = true;

                                    logger.trace( "Proxy request header:\n{}\n", new String( bReq.toByteArray() ) );
                                }
                            }
                            finally
                            {
                                lastFour.remove( lastFour.size() - 1 );
                            }
                        }
                        default:
                        {
                            pReq.print( c );
                            lastFour.add( c );
                        }
                    }
                }
            }
            else
            {
                bReq.write( bbuf );
            }
        }
    }

}


/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.core.ctl;

import static org.commonjava.maven.galley.util.UrlUtils.buildUrl;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;

import org.apache.commons.io.IOUtils;
import org.apache.http.HttpHost;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.commonjava.indy.IndyException;
import org.commonjava.indy.IndyWorkflowException;
import org.commonjava.indy.audit.ChangeSummary;
import org.commonjava.indy.data.IndyDataException;
import org.commonjava.indy.data.StoreDataManager;
import org.commonjava.indy.model.core.ArtifactStore;
import org.commonjava.indy.model.core.Group;
import org.commonjava.indy.model.core.HostedRepository;
import org.commonjava.indy.model.core.RemoteRepository;
import org.commonjava.indy.model.core.StoreKey;
import org.commonjava.indy.model.core.StoreType;
import org.commonjava.indy.model.core.dto.EndpointView;
import org.commonjava.indy.model.core.dto.EndpointViewListing;
import org.commonjava.indy.model.core.dto.ReplicationAction;
import org.commonjava.indy.model.core.dto.ReplicationAction.ActionType;
import org.commonjava.indy.model.core.dto.ReplicationDTO;
import org.commonjava.indy.model.core.dto.StoreListingDTO;
import org.commonjava.indy.subsys.http.IndyHttpException;
import org.commonjava.indy.subsys.http.IndyHttpProvider;
import org.commonjava.indy.subsys.http.util.HttpResources;
import org.commonjava.indy.subsys.template.IndyGroovyException;
import org.commonjava.indy.subsys.template.ScriptEngine;
import org.commonjava.maven.galley.event.EventMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.databind.ObjectMapper;

@ApplicationScoped
public class ReplicationController
{
    private static final String REPLICATION_ORIGIN = "replication";

    private static final String REPLICATION_REPO_CREATOR_SCRIPT = "replication-repo-creator.groovy";

    private final Logger logger = LoggerFactory.getLogger( getClass() );

    @Inject
    private StoreDataManager data;

    @Inject
    private ObjectMapper serializer;

    @Inject
    private IndyHttpProvider http;

    @Inject
    private ScriptEngine scriptEngine;

    protected ReplicationController()
    {
    }

    public ReplicationController( final StoreDataManager data, final IndyHttpProvider http, ScriptEngine scriptEngine,
                                  final ObjectMapper serializer )
    {
        this.data = data;
        this.http = http;
        this.scriptEngine = scriptEngine;
        this.serializer = serializer;
    }

    public ReplicationRepositoryCreator createRepoCreator()
    {
        ReplicationRepositoryCreator creator = null;
        try
        {
            creator = scriptEngine.parseStandardScriptInstance( ScriptEngine.StandardScriptType.store_creators,
                                                                REPLICATION_REPO_CREATOR_SCRIPT,
                                                                ReplicationRepositoryCreator.class );
        }
        catch ( IndyGroovyException e )
        {
            Logger logger = LoggerFactory.getLogger( getClass() );
            logger.error( String.format( "Cannot create ReplicationRepositoryCreator instance: %s. Disabling replication support.",
                                         e.getMessage() ), e );
        }
        return creator;
    }

    public Set<StoreKey> replicate( final ReplicationDTO dto, final String user )
        throws IndyWorkflowException
    {

        final ReplicationRepositoryCreator creator = createRepoCreator();

        if ( creator == null )
        {
            throw new IndyWorkflowException( 500, "Cannot replicate; ReplicationRepositoryCreator could not be instantiated." );
        }

        try
        {
            dto.validate();
        }
        catch ( final IndyException e )
        {
            throw new IndyWorkflowException( "Invalid replication request DTO: %s", e, e.getMessage() );
        }

        List<? extends ArtifactStore> remoteStores = null;
        List<EndpointView> remoteEndpoints = null;

        final boolean overwrite = dto.isOverwrite();
        final Set<StoreKey> replicated = new HashSet<StoreKey>();
        for ( final ReplicationAction action : dto )
        {
            if ( action == null )
            {
                continue;
            }

            logger.info( "Processing replication action:\n\n  {}\n\nin DTO: {}\n\n", action, dto );
            final String include = action.getInclude();
            final String exclude = action.getExclude();

            try
            {
                if ( action.getType() == ActionType.PROXY )
                {
                    if ( remoteEndpoints == null )
                    {
                        remoteEndpoints = getEndpoints( dto );
                    }

                    for ( final EndpointView view : remoteEndpoints )
                    {
                        final String key = "remote-" + view.getType() + "_" + view.getName();
                        if ( ( include == null || key.matches( include ) )
                            && ( exclude == null || !key.matches( exclude ) ) )
                        {
                            final StoreKey sk = new StoreKey( StoreType.remote, key );
                            if ( overwrite || !data.hasArtifactStore( sk ) )
                            {
                                RemoteRepository repo = creator.createRemoteRepository( key, view );
                                repo.setMetadata( ArtifactStore.METADATA_ORIGIN, REPLICATION_ORIGIN );

                                setProxyAttributes( repo, action );

                                data.storeArtifactStore( repo, new ChangeSummary( user,
                                                                                "REPLICATION: Proxying remote indy repository: "
                                                                                + view.getResourceUri() ),
                                                         true, true,
                                                         new EventMetadata().set( StoreDataManager.EVENT_ORIGIN,
                                                                                REPLICATION_ORIGIN ) );
                                replicated.add( repo.getKey() );
                            }
                        }
                    }
                }
                else if ( action.getType() == ActionType.MIRROR )
                {
                    if ( remoteStores == null )
                    {
                        remoteStores = getRemoteStores( dto );
                    }

                    for ( final ArtifactStore store : remoteStores )
                    {
                        final String key = store.getKey()
                                                .toString();
                        if ( ( include == null || key.matches( include ) )
                            && ( exclude == null || !key.matches( exclude ) ) )
                        {
                            if ( overwrite || !data.hasArtifactStore( store.getKey() ) )
                            {
                                if ( store instanceof RemoteRepository )
                                {
                                    setProxyAttributes( ( (RemoteRepository) store ), action );
                                }

                                data.storeArtifactStore( store, new ChangeSummary( user,
                                                                                "REPLICATION: Mirroring remote indy store: "
                                                                                + store.getKey() ),
                                                         true, true,
                                                         new EventMetadata().set( StoreDataManager.EVENT_ORIGIN,
                                                                                REPLICATION_ORIGIN ) );
                                replicated.add( store.getKey() );
                            }
                        }
                    }
                }
            }
            catch ( final IndyDataException e )
            {
                logger.error( e.getMessage(), e );
                throw new IndyWorkflowException( e.getMessage(), e );
            }
        }

        return replicated;

    }

    private void setProxyAttributes( final RemoteRepository repo, final ReplicationAction action )
    {
        if ( action.getProxyHost() != null )
        {
            repo.setProxyHost( action.getProxyHost() );

            if ( action.getProxyPort() > 0 )
            {
                repo.setProxyPort( action.getProxyPort() );
            }

            if ( action.getProxyUser() != null )
            {
                repo.setProxyUser( action.getProxyUser() );
            }

            if ( action.getProxyPass() != null )
            {
                repo.setProxyPassword( action.getProxyPass() );
            }
        }
    }

    // FIXME: Find a better solution to the passed-in generic deserialization problem...erasure is a mother...
    private List<? extends ArtifactStore> getRemoteStores( final ReplicationDTO dto )
        throws IndyWorkflowException
    {
        final String apiUrl = dto.getApiUrl();

        String remotesUrl = null;
        String groupsUrl = null;
        String hostedUrl = null;
        try
        {
            remotesUrl = buildUrl( apiUrl, "/admin/remotes" );
            groupsUrl = buildUrl( apiUrl, "/admin/groups" );
            hostedUrl = buildUrl( apiUrl, "/admin/hosted" );
        }
        catch ( final MalformedURLException e )
        {
            throw new IndyWorkflowException(
                                              "Failed to construct store definition-retrieval URL from api-base: {}. Reason: {}",
                                              e, apiUrl, e.getMessage() );
        }

        //        logger.info( "\n\n\n\n\n[AutoProx] Checking URL: {} from:", new Throwable(), url );
        final List<ArtifactStore> result = new ArrayList<ArtifactStore>();

        addStoresFrom( result, remotesUrl, dto, RemoteRepository.class );
        addStoresFrom( result, groupsUrl, dto, Group.class );
        addStoresFrom( result, hostedUrl, dto, HostedRepository.class );

        return result;
    }

    private <T extends ArtifactStore> void addStoresFrom( final List<ArtifactStore> result, final String remotesUrl,
                                                          final ReplicationDTO dto, final Class<T> type )
        throws IndyWorkflowException
    {
        final HttpGet req = newGet( remotesUrl, dto );
        CloseableHttpClient client = null;
        try
        {
            String siteId = new URL( remotesUrl ).getHost();

            client = http.createClient( siteId );
            CloseableHttpResponse response = client.execute( req, http.createContext( siteId ) );

            final StatusLine statusLine = response.getStatusLine();
            final int status = statusLine.getStatusCode();
            if ( status == HttpStatus.SC_OK )
            {
                final String json = HttpResources.entityToString( response );

                final StoreListingDTO<T> listing = serializer.readValue( json, serializer.getTypeFactory()
                                                                                .constructParametricType(
                                                                                StoreListingDTO.class,
                                                                                type ) );

                if ( listing != null )
                {
                    result.addAll( listing.getItems() );
                }
            }
            else
            {
                throw new IndyWorkflowException( status, "Request: %s failed: %s", remotesUrl, statusLine );
            }
        }
        catch ( final IOException | IndyHttpException e )
        {
            throw new IndyWorkflowException( "Failed to retrieve endpoints from: %s. Reason: %s", e, remotesUrl,
                                              e.getMessage() );
        }
        finally
        {
            IOUtils.closeQuietly( client );
        }
    }

    private HttpGet newGet( final String url, final ReplicationDTO dto )
    {
        final HttpGet get = new HttpGet( url );
        final int proxyPort = dto.getProxyPort();
        HttpHost proxy;
        if ( proxyPort < 1 )
        {
            proxy = new HttpHost( dto.getProxyHost(), -1, "http" );
        }
        else
        {
            proxy = new HttpHost( dto.getProxyHost(), dto.getProxyPort(), "http" );
        }

        final RequestConfig config = RequestConfig.custom()
                                                  .setProxy( proxy )
                                                  .build();
        get.setConfig( config );

        return get;
    }

    private List<EndpointView> getEndpoints( final ReplicationDTO dto )
        throws IndyWorkflowException
    {
        final String apiUrl = dto.getApiUrl();
        String url = null;
        try
        {
            url = buildUrl( apiUrl, "/stats/all-endpoints" );
        }
        catch ( final MalformedURLException e )
        {
            throw new IndyWorkflowException(
                                              "Failed to construct endpoint-retrieval URL from api-base: {}. Reason: {}",
                                              e, apiUrl, e.getMessage() );
        }

        final HttpGet req = newGet( url, dto );
        CloseableHttpClient client = null;
        try
        {
            String siteId = new URL( url ).getHost();

            client = http.createClient( siteId );

            CloseableHttpResponse response = client.execute( req, http.createContext( siteId ) );

            final StatusLine statusLine = response.getStatusLine();
            final int status = statusLine.getStatusCode();
            if ( status == HttpStatus.SC_OK )
            {
                final String json = HttpResources.entityToString( response );
                final EndpointViewListing listing = serializer.readValue( json, EndpointViewListing.class );
                return listing.getItems();
            }

            throw new IndyWorkflowException( status, "Endpoint request failed: {}", statusLine );
        }
        catch ( final IOException | IndyHttpException e )
        {
            throw new IndyWorkflowException( "Failed to retrieve endpoints from: {}. Reason: {}", e, url,
                                              e.getMessage() );
        }
        finally
        {
            IOUtils.closeQuietly( client );
        }
    }

}


/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.koji.content;

import com.redhat.red.build.koji.KojiClient;
import com.redhat.red.build.koji.KojiClientException;
import com.redhat.red.build.koji.model.xmlrpc.KojiArchiveInfo;
import com.redhat.red.build.koji.model.xmlrpc.KojiArchiveQuery;
import com.redhat.red.build.koji.model.xmlrpc.KojiBuildInfo;
import com.redhat.red.build.koji.model.xmlrpc.KojiSessionInfo;
import com.redhat.red.build.koji.model.xmlrpc.KojiTagInfo;
import org.commonjava.indy.koji.conf.IndyKojiConfig;
import org.commonjava.indy.subsys.infinispan.CacheHandle;
import org.commonjava.indy.subsys.infinispan.CacheProducer;
import org.commonjava.atlas.maven.ident.ref.ArtifactRef;
import org.commonjava.atlas.maven.ident.ref.ProjectRef;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.enterprise.context.ApplicationScoped;
import javax.inject.Inject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import static com.redhat.red.build.koji.model.xmlrpc.messages.Constants.GET_BUILD;

@ApplicationScoped
public class IndyKojiContentProvider
{
    private Logger logger = LoggerFactory.getLogger( getClass() );

    private final String KOJI_TAGS = "koji-tags"; // 99% immutable

    private final String KOJI_BUILD_INFO = "koji-buildInfoByIdOrNvr"; // immutable

    private final String KOJI_BUILD_INFO_CONTAINING_ARTIFACT = "koji-buildInfoContainingArtifact"; // volatile

    private final String KOJI_ARCHIVES_FOR_BUILD = "koji-ArchivesForBuild"; // immutable

    private final String KOJI_ARCHIVES_MATCHING_GA = "koji-ArchivesMatchingGA"; // volatile

    /**
     * This is to register ISPN caches produced by this provider so that the Metrics knows them.
     */
    public Set<String> getCacheNames()
    {
        Set<String> ret = new HashSet<>();
        if ( kojiConfig.isEnabled() && kojiConfig.isQueryCacheEnabled() )
        {
            ret.add( KOJI_TAGS );
            ret.add( KOJI_BUILD_INFO );
            ret.add( KOJI_BUILD_INFO_CONTAINING_ARTIFACT );
            ret.add( KOJI_ARCHIVES_FOR_BUILD );
            ret.add( KOJI_ARCHIVES_MATCHING_GA );
        }
        return ret;
    }

    @Inject
    private CacheProducer cacheProducer;

    @Inject
    private KojiClient kojiClient;

    @Inject
    private IndyKojiConfig kojiConfig;

    public IndyKojiContentProvider()
    {
    }

    public IndyKojiContentProvider( KojiClient kojiClient, CacheProducer cacheProducer )
    {
        this.kojiClient = kojiClient;
        this.cacheProducer = cacheProducer;
    }

    /**
     * The caller actually passes SimpleArtifactRef which has right hashcode/equals so we can use it as cache key.
     */
    public List<KojiBuildInfo> listBuildsContaining( ArtifactRef artifactRef, KojiSessionInfo session )
                    throws KojiClientException
    {
        return computeIfAbsent( KOJI_BUILD_INFO_CONTAINING_ARTIFACT, ArtifactRef.class, List.class, artifactRef,
                                () -> kojiClient.listBuildsContaining( artifactRef, session ),
                                kojiConfig.getQueryCacheTimeoutHours() );
    }

    public List<KojiTagInfo> listTags( Integer buildId, KojiSessionInfo session ) throws KojiClientException
    {
        return computeIfAbsent( KOJI_TAGS, Integer.class, List.class, buildId,
                                () -> kojiClient.listTags( buildId, session ) );
    }

    public Map<Integer, List<KojiTagInfo>> listTags( List<Integer> buildIds, KojiSessionInfo session )
                    throws KojiClientException
    {
        logger.debug( "listTags, buildIds: {}", buildIds );

        if ( !kojiConfig.isQueryCacheEnabled() )
        {
            logger.trace( "Cache not enabled, run direct kojiClient.listTags" );
            return kojiClient.listTags( buildIds, session );
        }

        CacheHandle<Integer, List> cache = cacheProducer.getCache( KOJI_TAGS );

        Map<Integer, List<KojiTagInfo>> map = new HashMap<>();
        List<Integer> missed = new ArrayList<>();
        for ( Integer buildId : buildIds )
        {
            List ret = cache.get( buildId );
            if ( ret != null )
            {
                map.put( buildId, ret );
            }
            else
            {
                missed.add( buildId );
            }
        }
        if ( !missed.isEmpty() )
        {
            Map<Integer, List<KojiTagInfo>> retrieved = kojiClient.listTags( missed, session );
            map.putAll( retrieved );
            retrieved.forEach( ( k, v ) -> cache.put( k, v ) );
        }
        return map;
    }

    public KojiBuildInfo getBuildInfo( Integer buildId, KojiSessionInfo session ) throws KojiClientException
    {
        return computeIfAbsent( KOJI_BUILD_INFO, Object.class, KojiBuildInfo.class, buildId,
                                () -> kojiClient.getBuildInfo( buildId, session ) );
    }

    public KojiBuildInfo getBuildInfo( String nvr, KojiSessionInfo session ) throws KojiClientException
    {
        return computeIfAbsent( KOJI_BUILD_INFO, Object.class, KojiBuildInfo.class, nvr,
                                () -> kojiClient.getBuildInfo( nvr, session ) );
    }

    public List<KojiBuildInfo> getBuildInfo( List<Object> args, KojiSessionInfo session ) throws KojiClientException
    {
        logger.debug( "getBuildInfo, args: {}", args );

        if ( !kojiConfig.isQueryCacheEnabled() )
        {
            logger.trace( "Cache not enabled, run direct kojiClient.multiCall" );
            return kojiClient.multiCall( GET_BUILD, args, KojiBuildInfo.class, session );
        }

        CacheHandle<Object, KojiBuildInfo> cache = cacheProducer.getCache( KOJI_BUILD_INFO );

        Map<Object, KojiBuildInfo> m = new HashMap<>();
        List<Object> missed = new ArrayList<>();
        for ( Object obj : args )
        {
            KojiBuildInfo kojiBuildInfo = cache.get( obj );
            if ( kojiBuildInfo != null )
            {
                m.put( obj, kojiBuildInfo );
            }
            else
            {
                missed.add( obj );
            }
        }
        if ( !missed.isEmpty() )
        {
            List<KojiBuildInfo> retrieved = kojiClient.multiCall( GET_BUILD, missed, KojiBuildInfo.class, session );
            for ( int i = 0; i < missed.size(); i++ )
            {
                Object obj = missed.get( i );
                KojiBuildInfo kojiBuildInfo = retrieved.get( i );
                m.put( obj, kojiBuildInfo );
                cache.put( obj, kojiBuildInfo );
            }
        }

        List<KojiBuildInfo> ret = new ArrayList<>();
        args.forEach( (a) -> ret.add( m.get( a ) ) ); // keep the order of results equal to args
        return ret;
    }

    // NOTE: This also enriches the archive results!
    public List<KojiArchiveInfo> listArchivesMatching( ProjectRef ga, KojiSessionInfo session )
                    throws KojiClientException
    {
        KojiArchiveQuery query = new KojiArchiveQuery( ga );
        return computeIfAbsent( KOJI_ARCHIVES_MATCHING_GA, ProjectRef.class, List.class, ga,
                                () ->{
                                    List<KojiArchiveInfo> archives = kojiClient.listArchives( query, session );
                                    kojiClient.enrichArchiveTypeInfo( archives, session );

                                    return archives;
                                },
                                kojiConfig.getQueryCacheTimeoutHours() );
    }

    // NOTE: This also enriches the archive results!
    public List<KojiArchiveInfo> listArchivesForBuild( Integer buildId, KojiSessionInfo session )
                    throws KojiClientException
    {
        KojiArchiveQuery query = new KojiArchiveQuery().withBuildId( buildId );
        return computeIfAbsent( KOJI_ARCHIVES_FOR_BUILD, Integer.class, List.class, buildId,
                                () -> {
                                    List<KojiArchiveInfo> archives = kojiClient.listArchives( query, session );
                                    kojiClient.enrichArchiveTypeInfo( archives, session );

                                    return archives;
                                } );
    }

    @FunctionalInterface
    private interface KojiContentSupplier<T>
    {
        T getKojiContent() throws KojiClientException;
    }

    private <K, V> V computeIfAbsent( String name, Class<K> kType, Class<V> vType, K key,
                                      KojiContentSupplier<V> supplier ) throws KojiClientException
    {
        return computeIfAbsent( name, kType, vType, key, supplier, 0 );
    }

    private <K, V> V computeIfAbsent( String name, Class<K> kType, Class<V> vType, K key,
                                      KojiContentSupplier<V> supplier, int expirationHours )
                    throws KojiClientException
    {
        logger.debug( "computeIfAbsent, cache: {}, key: {}", name, key );

        if ( !kojiConfig.isQueryCacheEnabled() )
        {
            logger.trace( "Cache not enabled, run direct supplier.getKojiContent" );
            return supplier.getKojiContent();
        }

        CacheHandle<K, V> cache = cacheProducer.getCache( name );
        V ret = cache.get( key );
        if ( ret == null )
        {
            logger.trace( "Entry not found, run supplier and put, expirationHours: {}", expirationHours );
            ret = supplier.getKojiContent();
            if ( expirationHours > 0 )
            {
                cache.put( key, ret, expirationHours, TimeUnit.HOURS );
            }
            else
            {
                cache.put( key, ret );
            }
        }

        if ( ret instanceof List )
        {
            ret = (V) new ArrayList( (List) ret ); // protective copy
        }
        logger.trace( "Return value, cache: {}, key: {}, ret: {}", name, key, ret );
        return ret;
    }

}


/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.folo;

import org.commonjava.indy.folo.model.TrackedContent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Set;
import java.util.function.Consumer;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;

import static org.apache.commons.io.IOUtils.copy;
import static org.commonjava.indy.folo.ctl.FoloConstants.TRACKING_TYPE.SEALED;

public class FoloUtils
{
    private static final Logger logger = LoggerFactory.getLogger( FoloUtils.class );

    /**
     * Write sealed records to a zip file.
     */
    public static void zipTrackedContent( File out, Set<TrackedContent> sealed ) throws IOException
    {
        logger.info( "Writing sealed zip to: '{}'", out.getAbsolutePath() );

        try (ZipOutputStream zip = new ZipOutputStream( new FileOutputStream( out ) ))
        {
            for ( TrackedContent f : sealed )
            {
                String name = SEALED.getValue() + "/" + f.getKey().getId();

                logger.trace( "Adding {} to zip", name );
                zip.putNextEntry( new ZipEntry( name ) );
                copy( toInputStream( f ), zip );
            }
        }
    }

    public static void backupTrackedContent( File dir, Set<TrackedContent> sealed ) throws IOException
    {
        logger.info( "Backup sealed to: '{}', size: {}", dir.getAbsolutePath(), sealed.size() );
        for ( TrackedContent f : sealed )
        {
            String name = f.getKey().getId();
            logger.trace( "Adding {} to {}", name, dir );
            copy( toInputStream( f ), new FileOutputStream( new File( dir, name ) ) );
        }
    }

    /**
     * Read records from input stream and execute consumer function.
     * @param inputStream
     * @param consumer
     * @return count of records read from the stream
     */
    public static int readZipInputStreamAnd( InputStream inputStream, Consumer<TrackedContent> consumer )
                    throws IOException, ClassNotFoundException
    {
        int count = 0;
        try ( ZipInputStream stream = new ZipInputStream( inputStream ) )
        {
            ZipEntry entry;
            while((entry = stream.getNextEntry())!=null)
            {
                logger.trace( "Read entry: %s, len: %d", entry.getName(), entry.getSize() );
                ByteArrayOutputStream bos = new ByteArrayOutputStream();
                int len;
                byte[] buffer = new byte[1024];
                while ((len = stream.read( buffer)) > 0)
                {
                    bos.write(buffer, 0, len);
                }
                bos.close();

                ObjectInputStream ois = new ObjectInputStream( new ByteArrayInputStream( bos.toByteArray() ));
                TrackedContent record = (TrackedContent) ois.readObject();
                consumer.accept( record );
                count++;
            }
        }
        return count;
    }

    public static InputStream toInputStream( TrackedContent f ) throws IOException
    {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();

        ObjectOutputStream oos = new ObjectOutputStream( baos );
        oos.writeObject( f );
        oos.flush();
        oos.close();

        return new ByteArrayInputStream( baos.toByteArray() );
    }

}


/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.content;

import java.util.ArrayList;
import java.util.List;

import org.commonjava.indy.model.core.StoreKey;
import org.commonjava.indy.model.core.dto.DirectoryListingEntryDTO;
import org.commonjava.indy.model.galley.KeyedLocation;
import org.commonjava.maven.galley.model.ConcreteResource;

/**
 * Implementation of {@link ConcreteResource} which assumes locations are {@link KeyedLocation} instances, and provides access to the {@link StoreKey}s
 * they hold. Also provides a method for converting a list of these resources into a listing DTO.
 */
public class StoreResource
    extends ConcreteResource
{

    public StoreResource( final KeyedLocation location, final String... path )
    {
        super( location, path );
    }

    /**
     * Retrieve the {@link StoreKey} from the {@link KeyedLocation} referenced by this resource.
     */
    public StoreKey getStoreKey()
    {
        return ( (KeyedLocation) getLocation() ).getKey();
    }

    /**
     * Convert a series of {@link StoreResource}s into a DTO for use in generating browser or other directory listings.
     */
    public static List<DirectoryListingEntryDTO> convertToEntries( final List<StoreResource> items )
    {
        final List<DirectoryListingEntryDTO> entries = new ArrayList<DirectoryListingEntryDTO>();
        for ( final StoreResource resource : items )
        {
            entries.add( new DirectoryListingEntryDTO( resource.getStoreKey(), resource.getPath() ) );
        }
        return entries;
    }

}


/**
 * Copyright (C) 2011-2018 Red Hat, Inc. (https://github.com/Commonjava/indy)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *         http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.commonjava.indy.promote.data;

import com.codahale.metrics.Gauge;
import org.apache.commons.lang.StringUtils;
import org.commonjava.cdi.util.weft.DrainingExecutorCompletionService;
import org.commonjava.cdi.util.weft.ExecutorConfig;
import org.commonjava.cdi.util.weft.Locker;
import org.commonjava.cdi.util.weft.WeftExecutorService;
import org.commonjava.cdi.util.weft.WeftManaged;
import org.commonjava.indy.IndyWorkflowException;
import org.commonjava.indy.audit.ChangeSummary;
import org.commonjava.indy.content.ContentManager;
import org.commonjava.indy.content.DownloadManager;
import org.commonjava.indy.core.inject.GroupMembershipLocks;
import org.commonjava.indy.core.inject.StoreContentLocks;
import org.commonjava.indy.data.IndyDataException;
import org.commonjava.indy.data.StoreDataManager;
import org.commonjava.indy.measure.annotation.Measure;
import org.commonjava.indy.metrics.IndyMetricsManager;
import org.commonjava.indy.model.core.ArtifactStore;
import org.commonjava.indy.model.core.Group;
import org.commonjava.indy.model.core.HostedRepository;
import org.commonjava.indy.model.core.StoreKey;
import org.commonjava.indy.model.core.StoreType;
import org.commonjava.indy.pkg.maven.model.MavenPackageTypeDescriptor;
import org.commonjava.indy.promote.callback.PromotionCallbackHelper;
import org.commonjava.indy.promote.change.event.PathsPromoteCompleteEvent;
import org.commonjava.indy.promote.change.event.PromoteCompleteEvent;
import org.commonjava.indy.promote.conf.PromoteConfig;
import org.commonjava.indy.promote.model.GroupPromoteRequest;
import org.commonjava.indy.promote.model.GroupPromoteResult;
import org.commonjava.indy.promote.model.PathsPromoteRequest;
import org.commonjava.indy.promote.model.PathsPromoteResult;
import org.commonjava.indy.promote.model.ValidationResult;
import org.commonjava.indy.promote.validate.PromotionValidationException;
import org.commonjava.indy.promote.validate.PromotionValidator;
import org.commonjava.indy.promote.validate.model.ValidationRequest;
import org.commonjava.indy.util.LocationUtils;
import org.commonjava.maven.galley.event.EventMetadata;
import org.commonjava.maven.galley.model.ConcreteResource;
import org.commonjava.maven.galley.model.Transfer;
import org.commonjava.maven.galley.model.TransferOperation;
import org.commonjava.maven.galley.spi.nfc.NotFoundCache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import javax.annotation.PostConstruct;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.event.Event;
import javax.inject.Inject;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace;
import static org.commonjava.indy.change.EventUtils.fireEvent;
import static org.commonjava.indy.core.ctl.PoolUtils.detectOverload;
import static org.commonjava.indy.core.ctl.PoolUtils.detectOverloadVoid;
import static org.commonjava.indy.data.StoreDataManager.IGNORE_READONLY;
import static org.commonjava.indy.model.core.StoreType.hosted;

/**
 * Component responsible for orchestrating the transfer of artifacts from one store to another, according to the given {@link PathsPromoteRequest} or
 * {@link PathsPromoteResult}. Currently provides promotePaths, resumePathsPromote, and rollbackPathsPromote (the latter two for dealing with failed promotePaths calls).
 *
 * @author jdcasey
 */
@ApplicationScoped
public class PromotionManager
{

    private static final String PROMOTION_TYPE = "promotion-type";

    private static final String PATH_PROMOTION = "path";

    private static final String GROUP_PROMOTION = "group";

    private static final String PROMOTION_SOURCE = "promotion-source";

    private static final String PROMOTION_TARGET = "promotion-target";

    private static final String PROMOTION_CONTENT_PATH = "promotion-content-path";

    private final Logger logger = LoggerFactory.getLogger( getClass() );

    @Inject
    private IndyMetricsManager metricsManager;

    @Inject
    private PromoteConfig config;

    @Inject
    private ContentManager contentManager;

    @Inject
    private DownloadManager downloadManager;

    @Inject
    private StoreDataManager storeManager;

    @Inject
    private PromotionValidator validator;

    @Inject
    private Event<PromoteCompleteEvent> promoteCompleteEvent;

    @StoreContentLocks
    @Inject
    private Locker<StoreKey> byPathTargetLocks;

    @GroupMembershipLocks
    @Inject
    private Locker<StoreKey> byGroupTargetLocks;

    private Map<String, StoreKey> targetGroupKeyMap = new ConcurrentHashMap<>( 1 );

    @WeftManaged
    @Inject
    @ExecutorConfig( named = "promotion", threads = 8, priority = 8, loadSensitive = ExecutorConfig.BooleanLiteral.TRUE )
    private WeftExecutorService asyncPromotionService;

    @WeftManaged
    @Inject
    @ExecutorConfig( named = "promotion-transfers", threads = 40, priority = 6, loadSensitive = ExecutorConfig.BooleanLiteral.TRUE,
                     maxLoadFactor = 100 )
    private WeftExecutorService transferService;

    @Inject
    private PromotionCallbackHelper callbackHelper;

    @Inject
    private NotFoundCache nfc;

    private final AtomicInteger pathPromotionTotal = new AtomicInteger();

    private final AtomicInteger pathPromotionCompleted = new AtomicInteger();

    private final AtomicInteger pathPromotionSkipped = new AtomicInteger();

    protected PromotionManager()
    {
    }

    @PostConstruct
    public void init()
    {
        Map<String, Gauge<Integer>> gauges = new HashMap<>();
        gauges.put( "total", () -> pathPromotionTotal.get() );
        gauges.put( "completed", () -> pathPromotionCompleted.get() );
        gauges.put( "skipped", () -> pathPromotionSkipped.get() );
        metricsManager.addGauges( this.getClass(), "path.promote.last", gauges );
    }

    public PromotionManager( PromotionValidator validator, final ContentManager contentManager,
                             final DownloadManager downloadManager, final StoreDataManager storeManager,
                             Locker<StoreKey> byPathTargetLocks, Locker<StoreKey> byGroupTargetLocks,
                             PromoteConfig config, NotFoundCache nfc, WeftExecutorService asyncPromotionService,
                             WeftExecutorService transferService )
    {
        this.validator = validator;
        this.contentManager = contentManager;
        this.downloadManager = downloadManager;
        this.storeManager = storeManager;
        this.byPathTargetLocks = byPathTargetLocks;
        this.byGroupTargetLocks = byGroupTargetLocks;
        this.config = config;
        this.nfc = nfc;
        this.asyncPromotionService = asyncPromotionService;
        this.transferService = transferService;
    }

    @Measure
    public GroupPromoteResult promoteToGroup( GroupPromoteRequest request, String user, String baseUrl )
            throws PromotionException, IndyWorkflowException
    {
        MDC.put( PROMOTION_TYPE, GROUP_PROMOTION );
        MDC.put( PROMOTION_SOURCE, request.getSource().toString() );
        MDC.put( PROMOTION_TARGET, request.getTargetKey().toString() );

        if ( !storeManager.hasArtifactStore( request.getSource() ) )
        {
            String error = String.format( "Cannot promote from missing source: %s", request.getSource() );
            logger.warn( error );

            return new GroupPromoteResult( request, error );
        }

        final StoreKey targetKey = getTargetKey( request.getTargetGroup() );

        if ( !storeManager.hasArtifactStore( targetKey ) )
        {
            String error = String.format( "No such target group: %s.", request.getTargetGroup() );
            logger.warn( error );

            return new GroupPromoteResult( request, error );
        }

        Future<GroupPromoteResult> future = submitGroupPromoteRequest( request, user, baseUrl );
        if ( request.isAsync() )
        {
            return new GroupPromoteResult( request ).accepted();
        }
        else
        {
            try
            {
                return future.get();
            }
            catch ( InterruptedException | ExecutionException e )
            {
                logger.error( "Group prromotion failed: " + request.getSource() + " -> " + request.getTargetKey(), e );
                throw new PromotionException( "Execution of group promotion failed.", e );
            }
        }
    }

    private ValidationResult doValidationAndPromote( GroupPromoteRequest request, AtomicReference<Exception> error,
                                                     String user, String baseUrl )
    {
        ValidationResult validation = new ValidationResult();
        logger.info( "Running validations for promotion of: {} to group: {}", request.getSource(),
                     request.getTargetGroup() );

        final StoreKey targetKey = getTargetKey( request.getTargetGroup() );
        byGroupTargetLocks.lockAnd( targetKey, config.getLockTimeoutSeconds(), k -> {
            Group target;
            try
            {
                target = (Group) storeManager.getArtifactStore( request.getTargetKey() );
            }
            catch ( IndyDataException e )
            {
                error.set( new PromotionException( "Cannot retrieve target group: %s. Reason: %s", e,
                                                   request.getTargetGroup(), e.getMessage() ) );
                return null;
            }

            try
            {
                ValidationRequest validationRequest = validator.validate( request, validation, baseUrl );

                if ( validation.isValid() )
                {
                    if ( !request.isDryRun() && !target.getConstituents().contains( request.getSource() ) )
                    {
                        // give the preUpdate event a different object to compare vs. the original group.
                        target = target.copyOf();
                        target.addConstituent( request.getSource() );
                        try
                        {
                            final ChangeSummary changeSummary = new ChangeSummary( user,
                                                                                "Promoting " + request.getSource()
                                                                                + " into membership of group: "
                                                                                + target.getKey() );

                            storeManager.storeArtifactStore( target, changeSummary, false, true, new EventMetadata() );
                            clearStoreNFC( validationRequest.getSourcePaths(), target );

                            if ( hosted == request.getSource().getType() && config.isAutoLockHostedRepos() )
                            {
                                HostedRepository source =
                                                (HostedRepository) storeManager.getArtifactStore( request.getSource() );

                                source.setReadonly( true );

                                final ChangeSummary readOnlySummary = new ChangeSummary( user, "Promoting "
                                                + request.getSource() + " into membership of group: " + target.getKey() );

                                storeManager.storeArtifactStore( source, readOnlySummary, false, true,
                                                                 new EventMetadata() );
                            }
                        }
                        catch ( IndyDataException e )
                        {
                            error.set( new PromotionException(
                                    "Failed to store group: %s with additional member: %s. Reason: %s", e,
                                    target.getKey(), request.getSource(), e.getMessage() ) );
                        }
                    }
                }
            }
            catch ( PromotionValidationException | IndyWorkflowException e )
            {
                error.set( e );
            }

            return null;
        }, ( k, lock ) -> {
            //FIXME: should we consider to repeat the promote process several times when lock failed?
            String errorMsg =
                    String.format( "Failed to acquire group promotion lock on target when promote: %s in %d seconds.",
                                   targetKey, config.getLockTimeoutSeconds() );
            logger.error( errorMsg );
            error.set( new PromotionException( errorMsg ) );

            return Boolean.FALSE;
        } );

        return validation;
    }

    /**
     * Provides target group store key for a given group name. This is meant to provide the same instance of the key
     * for a name to be able to synchronize promotion requests based on this instance.
     *
     * @param targetName the target group name
     * @return the group store key
     */
    private StoreKey getTargetKey( final String targetName )
    {
        return targetGroupKeyMap.computeIfAbsent( targetName,
                                                  k -> new StoreKey( MavenPackageTypeDescriptor.MAVEN_PKG_KEY,
                                                                     StoreType.group, targetName ) );
    }

    public GroupPromoteResult rollbackGroupPromote( GroupPromoteResult result, String user )
            throws PromotionException, IndyWorkflowException
    {
        GroupPromoteRequest request = result.getRequest();

        if ( !storeManager.hasArtifactStore( request.getSource() ) )
        {
            String error = String.format( "No such source/member store: %s", request.getSource() );
            logger.warn( error );

            return new GroupPromoteResult( request, error );
        }

        Group target;
        try
        {
            target = (Group) storeManager.getArtifactStore( request.getTargetKey() );
        }
        catch ( IndyDataException e )
        {
            throw new PromotionException( "Cannot retrieve target group: %s. Reason: %s", e, request.getTargetGroup(),
                                          e.getMessage() );
        }

        if ( target == null )
        {
            String error = String.format( "No such target group: %s.", request.getTargetGroup() );
            logger.warn( error );

            return new GroupPromoteResult( request, error );
        }

        Future<GroupPromoteResult> future = submitGroupPromoteRollback( result, target, user );
        if ( request.isAsync() )
        {
            return new GroupPromoteResult( request ).accepted();
        }
        else
        {
            try
            {
                return future.get();
            }
            catch ( InterruptedException | ExecutionException e )
            {
                logger.error( "Group promotion rollback failed: " + request.getSource() + " -> " + request.getTargetKey(), e );
                throw new PromotionException( "Execution of group promotion rollback failed.", e );
            }
        }
    }

    private GroupPromoteResult doGroupPromoteRollback( GroupPromoteResult result, Group target, String user )
            throws PromotionException
    {
        GroupPromoteResult ret;

        GroupPromoteRequest request = result.getRequest();

        if ( target.getConstituents().contains( request.getSource() ) )
        {
            // give the preUpdate event a different object to compare vs. the original group.
            target = target.copyOf();

            target.removeConstituent( request.getSource() );
            try
            {
                final ChangeSummary changeSummary = new ChangeSummary( user, "Removing " + request.getSource()
                                + " from membership of group: " + target.getKey() );

                storeManager.storeArtifactStore( target, changeSummary, false, true, new EventMetadata() );
            }
            catch ( IndyDataException e )
            {
                throw new PromotionException( "Failed to store group: %s with additional member: %s. Reason: %s", e,
                                              target.getKey(), request.getSource(), e.getMessage() );
            }
            ret = new GroupPromoteResult( request );
        }
        else
        {
            ret = new GroupPromoteResult( request, "Group: " + target.getKey() + " does not contain member: "
                    + request.getSource() );
        }

        return ret.withPromotionId( result.getPromotionId() );
    }

    private Future<GroupPromoteResult> submitGroupPromoteRollback( final GroupPromoteResult result, final Group target, final String user )
            throws IndyWorkflowException
    {
        return detectOverload( () -> asyncPromotionService.submit( () -> {
            GroupPromoteResult ret;
            try
            {
                ret = doGroupPromoteRollback( result, target, user );
            }
            catch ( Exception ex )
            {
                GroupPromoteRequest request = result.getRequest();
                String msg = "Group promotion rollback failed. Target: " + target.getKey() + ", Source: "
                        + request.getSource() + ", Reason: " + getStackTrace( ex );
                logger.warn( msg );
                ret = new GroupPromoteResult( request, msg ).withPromotionId( result.getPromotionId() );
            }

            if ( ret.getRequest().getCallback() != null )
            {
                return callbackHelper.callback( ret.getRequest().getCallback(), ret );
            }

            return ret;
        } ) );
    }

    private Future<GroupPromoteResult> submitGroupPromoteRequest( final GroupPromoteRequest request, final String user, final String baseUrl )
            throws IndyWorkflowException
    {
        return detectOverload( () -> asyncPromotionService.submit( () -> {
            AtomicReference<Exception> error = new AtomicReference<>();
            ValidationResult validation = doValidationAndPromote( request, error, user, baseUrl );

            GroupPromoteResult ret;
            Exception ex = error.get();
            if ( ex != null )
            {
                String msg = "Group promotion failed. Target: " + request.getTargetGroup() + ", Source: "
                        + request.getSource() + ", Reason: " + getStackTrace( ex );
                logger.warn( msg );
                ret = new GroupPromoteResult( request, msg );
            }
            else
            {
                ret = new GroupPromoteResult( request, validation );
            }

            if ( request.getCallback() != null )
            {
                return callbackHelper.callback( ret.getRequest().getCallback(), ret );
            }

            return ret;
        } ) );
    }

    /**
     * Promote artifacts from the source to the target given in the {@link PathsPromoteRequest}. If a set of paths are given, only try to promotePaths those.
     * Otherwise, build a recursive list of available artifacts in the source store, and try to promotePaths them all.
     *
     * @param request The request containing source and target store keys, and an optional list of paths to promotePaths
     *
     * @return The result, including the source and target store keys used, the paths completed (promoted successfully), the pending paths (those that
     * weren't processed due to some error...or null), and a nullable error explaining what (if anything) went wrong with the promotion.
     *
     * @throws PromotionException
     * @throws IndyWorkflowException
     */
    @Measure
    public PathsPromoteResult promotePaths( final PathsPromoteRequest request, final String baseUrl )
            throws PromotionException, IndyWorkflowException
    {
        MDC.put( PROMOTION_TYPE, PATH_PROMOTION );
        MDC.put( PROMOTION_SOURCE, request.getSource().toString() );
        MDC.put( PROMOTION_TARGET, request.getTargetKey().toString() );

        Future<PathsPromoteResult> future = submitPathsPromoteRequest( request, baseUrl );
        if ( request.isAsync() )
        {
            return new PathsPromoteResult( request ).accepted();
        }
        else
        {
            try
            {
                return future.get();
            }
            catch ( InterruptedException | ExecutionException e )
            {
                logger.error( "Path prromotion failed: " + request.getSource() + " -> " + request.getTargetKey(), e );
                throw new PromotionException( "Execution of path promotion failed.", e );
            }
        }
    }

    private PathsPromoteResult doPathsPromotion( PathsPromoteRequest request, String baseUrl )
            throws IndyWorkflowException, PromotionValidationException
    {
        final Set<String> paths = request.getPaths();
        final StoreKey source = request.getSource();

        List<Transfer> contents;
        if ( paths == null || paths.isEmpty() )
        {
            contents = downloadManager.listRecursively( source, DownloadManager.ROOT_PATH );
        }
        else
        {
            contents = getTransfersForPaths( source, paths );
        }

        final Set<String> pending = new HashSet<>();

        for ( final Transfer transfer : contents )
        {
            pending.add( transfer.getPath() );
        }

        AtomicReference<Exception> ex = new AtomicReference<>();

        PathsPromoteResult promoteResult = byPathTargetLocks.lockAnd( request.getTargetKey(), config.getLockTimeoutSeconds(), k -> {
            try
            {
                ValidationResult validation = new ValidationResult();
                ValidationRequest validationRequest = validator.validate( request, validation, baseUrl );
                if ( request.isDryRun() )
                {
                    return new PathsPromoteResult( request, pending, Collections.emptySet(), Collections.emptySet(),
                                                   validation );
                }
                else if ( validation.isValid() )
                {
                    PathsPromoteResult result = runPathPromotions( request, pending, Collections.emptySet(), Collections.emptySet(),
                                                                   contents, validation, validationRequest );
                    doPathPromoteMetrics( contents.size(), result );
                    return result;
                }
                else
                {
                    return new PathsPromoteResult( request, pending, Collections.emptySet(), Collections.emptySet(),
                                                   validation );
                }
            }
            catch ( PromotionValidationException e )
            {
                ex.set( e );
            }
            catch ( IndyWorkflowException e )
            {
                ex.set( e );
            }

            return null;
        }, ( k, lock ) -> {
            String error = String.format( "Failed to acquire promotion lock on target: %s in %d seconds.",
                                          request.getTargetKey(), config.getLockTimeoutSeconds() );

            logger.error( error );
            ex.set( new IndyWorkflowException( error ) );

            return false;
        } );

        Exception e = ex.get();
        if ( e != null )
        {
            if ( e instanceof PromotionValidationException )
            {
                throw (PromotionValidationException) e;
            }
            else if ( e instanceof IndyWorkflowException )
            {
                throw (IndyWorkflowException) e;
            }

            throw new IndyWorkflowException( "Promotion failed.", e );
        }

        return promoteResult;
    }

    private void doPathPromoteMetrics( int total, PathsPromoteResult result )
    {
        pathPromotionTotal.set( total );
        pathPromotionCompleted.set( result.getCompletedPaths().size() );
        pathPromotionSkipped.set( result.getSkippedPaths().size() );
    }

    private Future<PathsPromoteResult> submitPathsPromoteRequest( PathsPromoteRequest request, final String baseUrl )
            throws IndyWorkflowException
    {
        return detectOverload( () ->
            asyncPromotionService.submit( () -> {
                PathsPromoteResult ret;
                try
                {
                    ret = doPathsPromotion( request, baseUrl );
                }
                catch ( Exception ex )
                {
                    String msg =
                            "Path promotion failed. Target: " + request.getTarget() + ", Source: " + request.getSource()
                                    + ", Reason: " + getStackTrace( ex );
                    logger.warn( msg );
                    ret = new PathsPromoteResult( request, msg );
                }

                if ( ret.getRequest().getCallback() != null )
                {
                    return callbackHelper.callback( ret.getRequest().getCallback(), ret );
                }

                return ret;
            } )
        );
    }

    /**
     * Attempt to resumePathsPromote from a previously failing {@link PathsPromoteResult}. This is meant to handle cases where a transient (or correctable) error
     * occurs on the server side, and promotion can proceed afterward. It works much like the {@link #promotePaths(PathsPromoteRequest, String)} call, using the pending
     * paths list from the input result as the list of paths to process. The output {@link PathsPromoteResult} contains all previous completed paths PLUS
     * any additional completed transfers when it is returned, thus providing a cumulative result to the user.
     *
     * @param result The result to resumePathsPromote
     *
     * @return The same result, with any successful path promotions moved from the pending to completed paths list, and the error cleared (or set to a
     * new error)
     *
     * @throws PromotionException
     * @throws IndyWorkflowException
     */
    public PathsPromoteResult resumePathsPromote( final PathsPromoteResult result, final String baseUrl )
            throws PromotionException, IndyWorkflowException
    {
        final PathsPromoteRequest request = result.getRequest();

        Future<PathsPromoteResult> future = submitResumePathsPromote( result, baseUrl );
        if ( request.isAsync() )
        {
            return new PathsPromoteResult( request ).accepted();
        }
        else
        {
            try
            {
                return future.get();
            }
            catch ( InterruptedException | ExecutionException e )
            {
                logger.error( "Path promotion resume failed: " + request.getSource() + " -> " + request.getTargetKey(), e );
                throw new PromotionException( "Execution of path promotion resume failed.", e );
            }
        }
    }

    private Future<PathsPromoteResult> submitResumePathsPromote( PathsPromoteResult result, String baseUrl )
            throws IndyWorkflowException
    {
        return detectOverload( () -> asyncPromotionService.submit( () -> {
            PathsPromoteResult ret;
            try
            {
                ret = doResumePathsPromote( result, baseUrl );
            }
            catch ( Exception ex )
            {
                final PathsPromoteRequest request = result.getRequest();
                String msg =
                        "Path promotion failed. Target: " + request.getTarget() + ", Source: " + request.getSource()
                                + ", Reason: " + getStackTrace( ex );
                logger.warn( msg );
                ret = new PathsPromoteResult( request, msg ).withPromotionId( result.getPromotionId() );
            }

            if ( ret.getRequest().getCallback() != null )
            {
                return callbackHelper.callback( ret.getRequest().getCallback(), ret );
            }

            return ret;
        } ) );
    }

    private PathsPromoteResult doResumePathsPromote( PathsPromoteResult result, String baseUrl )
            throws IndyWorkflowException, PromotionValidationException
    {
        final PathsPromoteRequest request = result.getRequest();

        final List<Transfer> contents = getTransfersForPaths( request.getSource(), result.getPendingPaths() );

        ValidationResult validation = new ValidationResult();
        ValidationRequest validationRequest = validator.validate( request, validation, baseUrl );
        PathsPromoteResult ret = runPathPromotions( request, result.getPendingPaths(), result.getCompletedPaths(),
                                                    result.getSkippedPaths(), contents, result.getValidations(),
                                                    validationRequest );

        return ret.withPromotionId( result.getPromotionId() );
    }

    /**
     * Attempt to rollbackPathsPromote a previously failing {@link PathsPromoteResult}. This is meant to handle cases where an unrecoverable error
     * occurs on the server side, and promotion can NOT proceed afterward. All paths in the completed paths set are deleted from the target, if
     * possible. The output {@link PathsPromoteResult} contains the previous content, with any successfully removed target paths moved back from the
     * completed-paths list to the pending-paths list. If an error occurs during rollbackPathsPromote, the error field will be set...otherwise, it will be null.
     *
     * @param result The result to rollbackPathsPromote
     *
     * @return The same result, with any successful deletions moved from the completed to pending paths list, and the error cleared (or set to a
     * new error)
     *
     * @throws PromotionException
     * @throws IndyWorkflowException
     */
    public PathsPromoteResult rollbackPathsPromote( final PathsPromoteResult result )
            throws PromotionException, IndyWorkflowException
    {
        final PathsPromoteRequest request = result.getRequest();

        Future<PathsPromoteResult> future = submitRollbackPathsPromote( result );
        if ( request.isAsync() )
        {
            return new PathsPromoteResult( request ).accepted();
        }
        else
        {
            try
            {
                return future.get();
            }
            catch ( InterruptedException | ExecutionException e )
            {
                logger.error( "Path promotion rollback failed: " + request.getSource() + " -> " + request.getTargetKey(), e );
                throw new PromotionException( "Execution of path promotion rollback failed.", e );
            }
        }
    }

    private Future<PathsPromoteResult> submitRollbackPathsPromote( PathsPromoteResult result )
            throws IndyWorkflowException
    {
        return detectOverload( () -> asyncPromotionService.submit( () -> {
            PathsPromoteResult ret;
            try
            {
                ret = doRollbackPathsPromote( result );
            }
            catch ( Exception ex )
            {
                final PathsPromoteRequest request = result.getRequest();
                String msg = "Rollback path promotion failed. Target: " + request.getTarget() + ", Source: "
                        + request.getSource() + ", Reason: " + getStackTrace( ex );
                logger.warn( msg );
                ret = new PathsPromoteResult( request, msg ).withPromotionId( result.getPromotionId() );
            }

            if ( ret.getRequest().getCallback() != null )
            {
                return callbackHelper.callback( ret.getRequest().getCallback(), ret );
            }

            return ret;
        } ) );
    }

    private PathsPromoteResult doRollbackPathsPromote( PathsPromoteResult result )
            throws IndyWorkflowException
    {
        StoreKey targetKey = result.getRequest().getTarget();

        final List<Transfer> contents = getTransfersForPaths( targetKey, result.getCompletedPaths() );
        final Set<String> completed = result.getCompletedPaths();
        final Set<String> skipped = result.getSkippedPaths();

        if ( completed == null || completed.isEmpty() )
        {
            result.setError( null );
            return result;
        }

        Set<String> pending =
                        result.getPendingPaths() == null ? new HashSet<>() : new HashSet<>( result.getPendingPaths() );

        final AtomicReference<String> error = new AtomicReference<>();
        final boolean copyToSource = result.getRequest().isPurgeSource();

        ArtifactStore source = null;
        try
        {
            source = storeManager.getArtifactStore( result.getRequest().getSource() );
        }
        catch ( final IndyDataException e )
        {
            String msg =
                            String.format( "Failed to retrieve artifact store: %s. Reason: %s", result.getRequest().getSource(),
                                           e.getMessage() );

            logger.error( msg, e );
            error.set( msg );
        }

        AtomicReference<IndyWorkflowException> wfEx = new AtomicReference<>();
        if ( error.get() == null )
        {
            ArtifactStore src = source;
            byPathTargetLocks.lockAnd( targetKey, config.getLockTimeoutSeconds(), k -> {
                for ( final Transfer transfer : contents )
                {
                    if ( transfer != null && transfer.exists() )
                    {
                        try
                        {
                            if ( copyToSource )
                            {
                                final String path = transfer.getPath();
                                try (InputStream stream = transfer.openInputStream( true ))
                                {
                                    contentManager.store( src, path, stream, TransferOperation.UPLOAD,
                                                          new EventMetadata() );
                                }
                                catch ( IndyWorkflowException e )
                                {
                                    wfEx.set( e );
                                    return null;
                                }
                            }

                            transfer.delete( true );
                            completed.remove( transfer.getPath() );
                            pending.add( transfer.getPath() );
                        }
                        catch ( final IOException e )
                        {
                            String msg = String.format( "Failed to rollback path promotion of: %s from: %s. Reason: %s",
                                                        transfer, result.getRequest().getSource(), e.getMessage() );
                            logger.error( msg, e );
                            error.set( msg );
                        }
                    }
                }

                return null;
            }, ( k, lock ) -> {
                String msg = String.format( "Failed to acquire promotion lock on target: %s in %d seconds.", targetKey,
                                            config.getLockTimeoutSeconds() );
                logger.warn( msg );
                error.set( msg );

                return false;
            } );

            IndyWorkflowException wfException = wfEx.get();
            if ( wfException != null )
            {
                throw wfException;
            }

        }

        PathsPromoteResult ret = new PathsPromoteResult( result.getRequest(), pending, completed, skipped, error.get(),
                                                         new ValidationResult() );
        return ret.withPromotionId( result.getPromotionId() );
    }

    private PathsPromoteResult runPathPromotions( final PathsPromoteRequest request, final Set<String> pending,
                                                  final Set<String> prevComplete, final Set<String> prevSkipped,
                                                  final List<Transfer> contents, ValidationResult validation,
                                                  final ValidationRequest validationRequest )
            throws IndyWorkflowException
    {
        if ( pending == null || pending.isEmpty() )
        {
            return new PathsPromoteResult( request, pending, prevComplete, prevSkipped, validation );
        }

        StoreKey targetKey = request.getTarget();

        final Set<String> complete = prevComplete == null ? new HashSet<>() : new HashSet<>( prevComplete );
        final Set<String> skipped = prevSkipped == null ? new HashSet<>() : new HashSet<>( prevSkipped );

        List<String> errors = new ArrayList<>();

        ArtifactStore sourceStore = null;
        try
        {
            sourceStore = storeManager.getArtifactStore( request.getSource() );
        }
        catch ( IndyDataException e )
        {
            String msg = String.format( "Failed to retrieve artifact store: %s. Reason: %s", request.getSource(),
                                        e.getMessage() );
            errors.add( msg );
            logger.error( msg, e );
        }

        ArtifactStore targetStore = null;
        try
        {
            targetStore = storeManager.getArtifactStore( request.getTarget() );
        }
        catch ( IndyDataException e )
        {
            String msg = String.format( "Failed to retrieve artifact store: %s. Reason: %s", request.getTarget(),
                                        e.getMessage() );
            errors.add( msg );
            logger.error( msg, e );
        }

        if ( targetStore == null )
        {
            String msg = String.format( "Failed to retrieve artifact store: %s", request.getTarget() );
            errors.add( msg );
            logger.error( msg );
        }

        if ( errors.isEmpty() )
        {
            ArtifactStore src = sourceStore;
            ArtifactStore tgt = targetStore;

            AtomicReference<IndyWorkflowException> wfError = new AtomicReference<>();

            logger.info( "Running promotions from: {} (key: {})\n  to: {} (key: {})", src, request.getSource(), tgt,
                         request.getTarget() );

            DrainingExecutorCompletionService<PathTransferResult> svc =
                    new DrainingExecutorCompletionService<>( transferService );

            Set<PathTransferResult> results = new HashSet<>();
            detectOverloadVoid( () -> contents.forEach( ( transfer ) -> svc.submit(
                    newPathsPromoteTransfer( transfer, src, tgt, request ) ) ) );

            try
            {
                svc.drain( ptr -> results.add( ptr ) );
            }
            catch ( InterruptedException | ExecutionException e )
            {
                Set<String> paths;
                try
                {
                    paths = validationRequest.getSourcePaths();
                }
                catch ( PromotionValidationException e1 )
                {
                    paths = contents.stream()
                                    .map( txfr -> txfr.getPath() )
                                    .collect( Collectors.toSet() );
                }

                logger.error(
                        String.format( "Error waiting for promotion of: %s to: %s\nPaths:\n\n%s\n\n",
                                       request.getSource(), targetKey, paths ), e );
            }

            try
            {
                clearStoreNFC( validationRequest.getSourcePaths(), tgt );
            }
            catch ( IndyDataException | PromotionValidationException e )
            {
                String msg = String.format( "Failed to promote to: %s. Reason: %s", tgt, e.getMessage() );
                errors.add( msg );
                logger.error( msg, e );
            }

            if ( wfError.get() != null )
            {
                throw wfError.get();
            }

            if ( results != null )
            {
                results.forEach( pathResult -> {
                    if ( pathResult.traversed )
                    {
                        pending.remove( pathResult.path );
                    }

                    if ( pathResult.skipped )
                    {
                        skipped.add( pathResult.path );
                    }

                    if ( pathResult.completed )
                    {
                        complete.add( pathResult.path );
                    }

                    if ( pathResult.error != null )
                    {
                        errors.add( pathResult.error );
                    }
                } );
            }
        }

        String error = null;

        if ( !errors.isEmpty() )
        {
            error = StringUtils.join( errors, "\n" );
        }

        PathsPromoteResult result = new PathsPromoteResult( request, pending, complete, skipped, error, validation );

        if ( request.isFireEvents() )
        {
            PathsPromoteCompleteEvent evt = new PathsPromoteCompleteEvent( result );
            fireEvent( promoteCompleteEvent, evt );
        }

        return result;
    }

    private static final class PathTransferResult
    {
        public String error;

        public boolean traversed = false;

        public boolean skipped = false;

        public boolean completed = false;

        public final String path;

        public PathTransferResult( final String path )
        {
            this.path = path;
        }
    }

    private Callable<PathTransferResult> newPathsPromoteTransfer( final Transfer transfer, final ArtifactStore src,
                                                                  final ArtifactStore tgt,
                                                                  final PathsPromoteRequest request )
    {
        return () -> {
            MDC.put( PROMOTION_CONTENT_PATH, transfer.getPath() );

            PathTransferResult result = new PathTransferResult( transfer.getPath() );
            final String path = transfer.getPath();

            if ( !transfer.exists() )
            {
                result.traversed = true;
                result.skipped = true;
            }
            else
            {
                try
                {
                    Transfer target = contentManager.getTransfer( tgt, path, TransferOperation.UPLOAD );
                    //                        synchronized ( target )
                    //                        {
                    // TODO: Should the request object have an overwrite attribute? Is that something the user is qualified to decide?
                    if ( target != null && target.exists() )
                    {
                        logger.warn( "NOT promoting: {} from: {} to: {}. Target file already exists.", path,
                                     request.getSource(), request.getTarget() );

                        // TODO: There's no guarantee that the pre-existing content is the same!
                        result.traversed = true;
                        result.skipped = true;
                    }
                    else
                    {
                        try (InputStream stream = transfer.openInputStream( true ))
                        {
                            contentManager.store( tgt, path, stream, TransferOperation.UPLOAD,
                                                  new EventMetadata().set( IGNORE_READONLY, true ) );

                            result.traversed = true;
                            result.completed = true;

                            stream.close();

                            if ( request.isPurgeSource() )
                            {
                                contentManager.delete( src, path, new EventMetadata() );
                            }
                        }
                        catch ( final IOException e )
                        {
                            String msg = String.format( "Failed to open input stream for: %s. Reason: %s", transfer,
                                                        e.getMessage() );
                            result.error = msg;
                            logger.error( msg, e );
                        }
                    }

                }
                catch ( final IndyWorkflowException e )
                {
                    String msg = String.format( "Failed to promote path: %s to: %s. Reason: %s", transfer, tgt,
                                                e.getMessage() );
                    result.error = msg;
                    logger.error( msg, e );
                }
            }

            return result;
        };
    }

    private List<Transfer> getTransfersForPaths( final StoreKey source, final Set<String> paths )
            throws IndyWorkflowException
    {
        final List<Transfer> contents = new ArrayList<>();
        for ( final String path : paths )
        {
            final Transfer txfr = downloadManager.getStorageReference( source, path );
            if ( txfr == null || !txfr.exists() )
            {
                logger.warn( "Cannot promote path: '{}' from source: '{}'. It does not exist!", path, source );
                // TODO: Fail??
                continue;
            }

            contents.add( txfr );
        }

        return contents;
    }

    /**
     * NOTE: Adding sourcePaths parameter here to cut down on number of paths for clearing from NFC.
     *
     * @param sourcePaths The set of paths that need to be cleared from the NFC.
     * @param store The store whose affected groups should have their NFC entries cleared
     * @throws IndyDataException
     */
    private void clearStoreNFC( final Set<String> sourcePaths, ArtifactStore store )
            throws IndyDataException
    {
        Set<String> paths = sourcePaths.stream()
                                       .map( sp -> sp.startsWith( "/" ) && sp.length() > 1 ? sp.substring( 1 ) : sp )
                                       .collect( Collectors.toSet() );

        paths.forEach( path -> {
            ConcreteResource resource = new ConcreteResource( LocationUtils.toLocation( store ), path );

            logger.debug( "Clearing NFC path: {} from: {}\n\tResource: {}", path, store.getKey(), resource );
            nfc.clearMissing( resource );
        } );

        Set<Group> groups = storeManager.query().getGroupsAffectedBy( store.getKey() );
        if ( groups != null )
        {
            groups.forEach( group -> paths.forEach( path -> {
                ConcreteResource resource = new ConcreteResource( LocationUtils.toLocation( group ), path );

                logger.debug( "Clearing NFC path: {} from: {}\n\tResource: {}", path, group.getKey(), resource );
                nfc.clearMissing( resource );
            } ) );
        }
    }

}