package org.eclipse.aether.synccontext;

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *  http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collection;
import java.util.Collections;
import java.util.Deque;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.Map;
import java.util.TreeSet;

import javax.annotation.PreDestroy;
import javax.annotation.Priority;
import javax.inject.Named;
import javax.inject.Singleton;

import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.SyncContext;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.impl.SyncContextFactory;
import org.eclipse.aether.metadata.Metadata;
import org.eclipse.aether.util.ChecksumUtils;
import org.eclipse.aether.util.ConfigUtils;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A singleton factory to create synchronization contexts using Redisson's {@link RReadWriteLock}.
 * It locks fine-grained with groupId, artifactId and version if required.
 * <p>
 * <strong>Note: This component is still considered to be experimental, use with caution!</strong>
 * <h2>Configuration</h2>
 * You can configure various aspects of this factory.
 *
 * <h3>Redisson Client</h3>
 * To fully configure the Redisson client, this factory uses the following staggered approach:
 * <ol>
 * <li>If the property {@code aether.syncContext.redisson.configFile} is set and the file at that
 * specific path does exist, load it otherwise an exception is thrown.</li>
 * <li>If no configuration file path is provided, load default from
 * <code>${maven.conf}/maven-resolver-redisson.yaml</code>, but ignore if it does not exist.</li>
 * <li>If no configuration file is available at all, Redisson is configured with a single server pointing
 * to {@code redis://localhost:6379} with client name {@code maven-resolver}.</li>
 * </ol>
 * Please note that an invalid confguration file results in an exception too.
 *
 * <h3>Discrimination</h3>
 * You may freely use a single Redis instance to serve multiple Maven instances, on multiple hosts
 * with shared or exclusive local repositories. Every sync context instance will generate a unique
 * discriminator which identifies each host paired with the local repository currently accessed.
 * The following staggered approach is used:
 * <ol>
 * <li>Determine hostname, if not possible use {@code localhost}.</li>
 * <li>If the property {@code aether.syncContext.redisson.discriminator} is set, use it and skip
 * the remaining steps.</li>
 * <li>Concat hostname with the path of the local repository: <code>${hostname}:${maven.repo.local}</code>.</li>
 * <li>Calculate the SHA-1 digest of this value. If that fails use the static digest of an empty string.</li>
 * </ol>
 *
 * <h2>Key Composition</h2>
 * Each lock is assigned a unique key in the configured Redis instance which has the following pattern:
 * <code>maven:resolver:${discriminator}:${artifact|metadata}</code>.
 * <ul>
 * <li><code>${artifact}</code> will
 * always resolve to <code>artifact:${groupId}:${artifactId}:${baseVersion}</code>.</li>
 * <li><code>${metadata}</code> will resolve to one of <code>metadata:${groupId}:${artifactId}:${version}</code>,
 * <code>metadata:${groupId}:${artifactId}</code>, <code>metadata:${groupId}</code>,
 * <code>metadata:</code>.</li>
 * </ul>
 */
@Named
@Priority( Integer.MAX_VALUE )
@Singleton
public class RedissonSyncContextFactory
    implements SyncContextFactory
{

    private static final String DEFAULT_CONFIG_FILE_NAME = "maven-resolver-redisson.yaml";
    private static final String DEFAULT_REDIS_ADDRESS = "redis://localhost:6379";
    private static final String DEFAULT_CLIENT_NAME = "maven-resolver";
    private static final String DEFAULT_HOSTNAME = "localhost";
    private static final String DEFAULT_DISCRIMINATOR_DIGEST = "da39a3ee5e6b4b0d3255bfef95601890afd80709";

    private static final String CONFIG_PROP_CONFIG_FILE = "aether.syncContext.redisson.configFile";

    private static final Logger LOGGER = LoggerFactory.getLogger( RedissonSyncContextFactory.class );

    // We are in a singleton so these should exist only once!
    private RedissonClient redissonClient;
    private String hostname;

    public RedissonSyncContextFactory()
    {
        // TODO These two log statements will go away
        LOGGER.trace( "TCCL: {}", Thread.currentThread().getContextClassLoader() );
        LOGGER.trace( "CCL: {}", getClass().getClassLoader() );
        this.redissonClient = createRedissonClient();
        this.hostname = getHostname();
    }

    private RedissonClient createRedissonClient()
    {
        Path configFilePath = null;

        String configFile = ConfigUtils.getString( System.getProperties(), null, CONFIG_PROP_CONFIG_FILE );
        if ( configFile != null && !configFile.isEmpty() )
        {
            configFilePath = Paths.get( configFile );
            if ( Files.notExists( configFilePath ) )
            {
                throw new IllegalArgumentException( "The specified Redisson config file does not exist: "
                                                    + configFilePath );
            }
        }

        if ( configFilePath == null )
        {
            String mavenConf = ConfigUtils.getString( System.getProperties(), null, "maven.conf" );
            if ( mavenConf != null && !mavenConf.isEmpty() )
            {
                configFilePath = Paths.get( mavenConf, DEFAULT_CONFIG_FILE_NAME );
                if ( Files.notExists( configFilePath ) )
                {
                    configFilePath = null;
                }
            }
        }

        Config config = null;

        if ( configFilePath != null )
        {
            LOGGER.trace( "Reading Redisson config file from '{}'", configFilePath );
            try ( InputStream is = Files.newInputStream( configFilePath ) )
            {
                config = Config.fromYAML( is );
            }
            catch ( IOException e )
            {
                throw new IllegalStateException( "Failed to read Redisson config file: " + configFilePath, e );
            }
        }
        else
        {
            config = new Config();
            config.useSingleServer()
                .setAddress( DEFAULT_REDIS_ADDRESS )
                .setClientName( DEFAULT_CLIENT_NAME );
        }

        RedissonClient redissonClient = Redisson.create( config );
        LOGGER.trace( "Created Redisson client with id '{}'", redissonClient.getId() );

        return redissonClient;
    }

    private String getHostname()
    {
        try
        {
            return InetAddress.getLocalHost().getHostName();
        }
        catch ( UnknownHostException e )
        {
            LOGGER.warn( "Failed to get hostname, using '{}'",
                         DEFAULT_HOSTNAME, e );
            return DEFAULT_HOSTNAME;
        }
    }

    public SyncContext newInstance( RepositorySystemSession session, boolean shared )
    {
        // This log statement will go away
        LOGGER.trace( "Instance: {}", this );
        return new RedissonSyncContext( session, hostname, redissonClient, shared );
    }

    @PreDestroy
    public void shutdown()
    {
        LOGGER.trace( "Shutting down Redisson client with id '{}'", redissonClient.getId() );
        redissonClient.shutdown();
    }

    static class RedissonSyncContext
        implements SyncContext
    {

        private static final String CONFIG_PROP_DISCRIMINATOR = "aether.syncContext.redisson.discriminator";

        private static final String KEY_PREFIX = "maven:resolver:";

        private static final Logger LOGGER = LoggerFactory.getLogger( RedissonSyncContext.class );

        private final RepositorySystemSession session;
        private final String hostname;
        private final RedissonClient redissonClient;
        private final boolean shared;
        private final Map<String, RReadWriteLock> locks = new LinkedHashMap<>();

        private RedissonSyncContext( RepositorySystemSession session, String hostname,
                RedissonClient redissonClient, boolean shared )
        {
            this.session = session;
            this.hostname = hostname;
            this.redissonClient = redissonClient;
            this.shared = shared;
        }

        public void acquire( Collection<? extends Artifact> artifacts,
                Collection<? extends Metadata> metadatas )
        {
            // Deadlock prevention: https://stackoverflow.com/a/16780988/696632
            // We must acquire multiple locks always in the same order!
            Collection<String> keys = new TreeSet<>();
            if ( artifacts != null )
            {
                for ( Artifact artifact : artifacts )
                {
                    // TODO Should we include extension and classifier too?
                    String key = "artifact:" + artifact.getGroupId() + ":"
                            + artifact.getArtifactId() + ":" + artifact.getBaseVersion();
                    keys.add( key );
                }
            }

            if ( metadatas != null )
            {
                for ( Metadata metadata : metadatas )
                {
                    StringBuilder key = new StringBuilder( "metadata:" );
                    if ( !metadata.getGroupId().isEmpty() )
                    {
                        key.append( metadata.getGroupId() );
                        if ( !metadata.getArtifactId().isEmpty() )
                        {
                            key.append( ':' ).append( metadata.getArtifactId() );
                            if ( !metadata.getVersion().isEmpty() )
                            {
                                key.append( ':' ).append( metadata.getVersion() );
                            }
                        }
                    }
                    keys.add( key.toString() );
                }
            }

            if ( keys.isEmpty() )
            {
                return;
            }

            String discriminator = createDiscriminator();
            LOGGER.trace( "Using Redis key discriminator '{}' during this session", discriminator );

            LOGGER.trace( "Need {} {} lock(s) for {}", keys.size(), shared ? "read" : "write", keys );
            int acquiredLockCount = 0;
            int reacquiredLockCount = 0;
            for ( String key : keys )
            {
                RReadWriteLock rwLock = locks.get( key );
                if ( rwLock == null )
                {
                    rwLock = redissonClient
                            .getReadWriteLock( KEY_PREFIX + discriminator + ":" + key );
                    locks.put( key, rwLock );
                    acquiredLockCount++;
                }
                else
                {
                    reacquiredLockCount++;
                }

                RLock actualLock = shared ? rwLock.readLock() : rwLock.writeLock();
                // Avoid #getHoldCount() and #isLocked() roundtrips when we are not logging
                if ( LOGGER.isTraceEnabled() )
                {
                    LOGGER.trace( "Acquiring {} lock for '{}' (currently held: {}, already locked: {})",
                                  shared ? "read" : "write", key, actualLock.getHoldCount(),
                                  actualLock.isLocked() );
                }
                // If this still produces a deadlock we might need to switch to #tryLock() with n attempts
                actualLock.lock();
            }
            LOGGER.trace( "Total new locks acquired: {}, total existing locks reacquired: {}",
                          acquiredLockCount, reacquiredLockCount );
        }

        private String createDiscriminator()
        {
            String discriminator = ConfigUtils.getString( session, null, CONFIG_PROP_DISCRIMINATOR );

            if ( discriminator == null || discriminator.isEmpty() )
            {

                File basedir = session.getLocalRepository().getBasedir();
                discriminator = hostname + ":" + basedir;
                try
                {
                    Map<String, Object> checksums = ChecksumUtils.calc(
                            discriminator.toString().getBytes( StandardCharsets.UTF_8 ),
                            Collections.singletonList( "SHA-1" ) );
                    Object checksum = checksums.get( "SHA-1" );

                    if ( checksum instanceof Exception )
                    {
                        throw (Exception) checksum;
                    }

                    return String.valueOf( checksum );
                }
                catch ( Exception e )
                {
                    // TODO Should this be warn?
                    LOGGER.trace( "Failed to calculate discriminator digest, using '{}'",
                                  DEFAULT_DISCRIMINATOR_DIGEST, e );
                    return DEFAULT_DISCRIMINATOR_DIGEST;
                }
            }

            return discriminator;
        }

        public void close()
        {
            if ( locks.isEmpty() )
            {
                return;
            }

            // Release locks in reverse insertion order
            Deque<String> keys = new LinkedList<>( locks.keySet() );
            Iterator<String> keysIter = keys.descendingIterator();
            while ( keysIter.hasNext() )
            {
                String key = keysIter.next();
                RReadWriteLock rwLock = locks.get( key );
                RLock actualLock = shared ? rwLock.readLock() : rwLock.writeLock();
                while ( actualLock.getHoldCount() > 0 )
                {
                    // Avoid #getHoldCount() roundtrips when we are not logging
                    if ( LOGGER.isTraceEnabled() )
                    {
                        LOGGER.trace( "Releasing {} lock for '{}' (currently held: {})",
                                      shared ? "read" : "write", key, actualLock.getHoldCount() );
                    }
                    actualLock.unlock();
                }
            }
            // TODO Should we count reentrant ones too?
            LOGGER.trace( "Total locks released: {}", locks.size() );
            locks.clear();
        }

    }

}
