package com.atlassian.bitbucket.mesh.test.cluster;

import com.atlassian.bitbucket.mesh.io.IoConsumer;
import com.atlassian.bitbucket.mesh.io.MoreFiles;
import com.atlassian.bitbucket.mesh.rpc.CapabilityServiceGrpc;
import com.atlassian.bitbucket.mesh.rpc.RpcGetCapabilitiesRequest;
import com.google.common.base.Throwables;
import io.grpc.ManagedChannel;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.mutable.MutableBoolean;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.event.Level;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.CodeSource;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static java.util.Collections.unmodifiableCollection;
import static java.util.Objects.requireNonNull;
import static java.util.stream.Collectors.toList;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;

/**
 * Manager to be used in integration tests to provision a cluster of Mesh nodes. {@link MeshClusterManager} integrates
 * with the Mesh control plane through the {@link MeshControlPlane} interface.
 *
 * @see MeshCluster
 * @see MeshControlPlane
 * @see MeshClusterTestExecutionListener
 */
public class MeshClusterManager {

    private static final String MESH_APPLICATION_CLASS = "com.atlassian.bitbucket.mesh.boot.MeshApplication";
    private static final BlockingQueue<Integer> availableListenPorts;
    private static final Logger log = LoggerFactory.getLogger(MeshClusterManager.class);

    private static String MESH_CLASSPATH;
    private static Path MESH_JAR_PATH;
    private final ConcurrentMap<DefaultManagedMeshClusterNode, Integer> boundListenPorts;
    private final Set<Integer> claimedPorts;
    private final ConsoleLogProcessor consoleLogProcessor;
    private final MeshControlPlane controlPlane;
    private final ExecutorService executor;
    private final Path homesRoot;
    private final Path meshJarPath;
    private final ConcurrentMap<String, DefaultManagedMeshClusterNode> nodes;
    private Level logLevel;
    private boolean logged;

    static {
        String property = System.getProperty("mesh.cluster.debug.connect.ports");
        if (property != null) {
            availableListenPorts = new LinkedBlockingQueue<>();
            Pattern.compile(",").splitAsStream(property)
                    .map(String::trim)
                    .mapToInt(Integer::parseInt)
                    .forEach(availableListenPorts::add);
        } else {
            availableListenPorts = null;
        }
    }

    public MeshClusterManager(@Nonnull MeshControlPlane controlPlane, @Nonnull ExecutorService executor,
                              @Nonnull Path homesRoot, @Nullable Path meshJarPath) {
        this.controlPlane = requireNonNull(controlPlane, "controlPlane");
        this.executor = requireNonNull(executor, "executor");
        this.homesRoot = requireNonNull(homesRoot, "homesRoot");
        this.meshJarPath = meshJarPath == null ? findMeshJarPathOnClasspath() : meshJarPath;

        boundListenPorts = new ConcurrentHashMap<>();
        claimedPorts = new HashSet<>();
        consoleLogProcessor = new ConsoleLogProcessor();
        logLevel = Level.WARN;
        nodes = new ConcurrentHashMap<>();
    }

    @Nonnull
    public Optional<ManagedMeshClusterNode> findNodeById(@Nonnull String nodeId) {
        return nodes.values().stream()
                .filter(node -> nodeId.equals(node.getId()))
                .map(ManagedMeshClusterNode.class::cast)
                .findFirst();
    }

    /**
     * @return all nodes created by this manager, including ones that are currently not registered with the control plane
     */
    @Nonnull
    public Collection<ManagedMeshClusterNode> getAllNodes() {
        return unmodifiableCollection(nodes.values());
    }

    @Nonnull
    public ManagedMeshClusterNode getNodeById(@Nonnull String nodeId) {
        return findNodeById(nodeId)
                .orElseThrow(() -> new NoSuchElementException("No Mesh node with ID [" + nodeId + "]"));
    }

    /**
     * @return all nodes created by this manager that are currently registered with the control plane
     */
    @Nonnull
    public Collection<ManagedMeshClusterNode> getNodes() {
        return nodes.values().stream()
                .filter(DefaultManagedMeshClusterNode::isPartOfCluster)
                .collect(toImmutableList());
    }

    public void initCluster(@Nonnull MeshClusterConfig config) {
        logLevel = requireNonNull(config, "config").getLogLevel();

        Instant start = null;
        if (!logged) {
            logged = true;
            start = Instant.now();
            log.warn("Initializing {} node cluster (homes root = {})", config.getSize(), homesRoot);
        }

        //Provision any required nodes
        int toProvision = config.getSize() - nodes.size();
        for (int i = 0; i < toProvision; ++i) {
            int port = selectPort(0);
            ManagedMeshClusterNode node = provision(new ProvisionNodeRequest.Builder()
                    .authenticationEnabled(config.isAuthenticationEnabled())
                    .name("git-" + port)
                    .port(port)
                    .shards(config.getShardsPerNode())
                    .build());
            node.setLogLevel(logLevel);
        }

        CompletionService<Void> completionService = new ExecutorCompletionService<>(executor);
        int count = 0;
        int tasks = 0;
        for (ManagedMeshClusterNode node : nodes.values()) {
            if (++count <= config.getSize()) {
                //Ensure the first nodeCount nodes are running and joined to the cluster
                completionService.submit(() -> {
                    node.setLogLevel(logLevel);
                    if (!node.isRunning()) {
                        //Start the node and wait for it to be ready
                        node.start().get();
                    }
                    //TODO: update the node to have the desired number of shards
                    node.joinCluster();
                    return null;
                });
                tasks++;
            } else {
                //Remove any excess nodes from the cluster, but don't shut them down in case we want/need to reuse them
                //for later tests
                node.leaveCluster(false);
            }
        }

        try {
            for (int i = 0; i < tasks; ++i) {
                completionService.take().get();
            }
            if (start != null) {
                log.warn("Cluster initialized in {}s", Duration.between(start, Instant.now()).getSeconds());
            }
        } catch (ExecutionException e) {
            Throwable cause = Throwables.getRootCause(e);
            Throwables.throwIfUnchecked(cause);
            throw new RuntimeException("Failed to init the cluster", cause);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Interrupted while waiting for the cluster nodes to start up", e);
        }
    }

    public void initCluster(int nodeCount, int shardsPerNode) {
        initCluster(new MeshClusterConfig.Builder(nodeCount)
                .shardsPerNode(shardsPerNode)
                .build());
    }

    @Nonnull
    public DefaultManagedMeshClusterNode provision(@Nonnull ProvisionNodeRequest request) {
        MutableBoolean created = new MutableBoolean();
        DefaultManagedMeshClusterNode node = nodes.computeIfAbsent(request.getName(), ignored -> {
            //Initialize the node dir and all data store dirs
            Path homeDir = request.getHomeDir();
            if (!homeDir.isAbsolute()) {
                homeDir = homesRoot.resolve(homeDir);
            }

            int port = selectPort(request.getPort());
            ManagedNodeBuilder builder = new ManagedNodeBuilder(request.getName())
                    .authenticationEnabled(request.isAuthenticationEnabled())
                    .homeDir(homeDir)
                    .port(port)
                    .url("http://localhost:" + port);
            //Create the shard directories
            for (int i = 0; i < request.getShardCount(); ++i) {
                Path shardDir = Paths.get("shards", "shard-" + (i + 1));
                builder.shards(shardDir);
                MoreFiles.mkdir(homeDir.resolve(shardDir));
            }
            created.setTrue();
            return builder.build();
        });
        if (created.booleanValue()) {
            return node;
        }

        throw new IllegalStateException("Node " + request.getName() + " has already been provisioned");
    }

    public void reset() {
        nodes.values().forEach(DefaultManagedMeshClusterNode::reset);
    }

    public void setLogLevel(@Nonnull Level logLevel) {
        this.logLevel = requireNonNull(logLevel, "logLevel");
        nodes.values().forEach(node -> node.setLogLevel(logLevel));
    }

    public void shutdown() {
        //Unregister all nodes from the control plane and stop any that are running
        for (DefaultManagedMeshClusterNode node : nodes.values()) {
            try {
                node.leaveCluster(true);
            } catch (RuntimeException e) {
                log.warn("Mesh node {} failed to leave the cluster", node.getName(), e);
            }
        }
        claimedPorts.clear();
        nodes.clear();

        if (homesRoot != null) {
            MoreFiles.deleteQuietly(homesRoot);
        }
        consoleLogProcessor.shutdown();
    }

    private static Path findMeshJarPathOnClasspath() {
        if (MESH_JAR_PATH == null && MESH_CLASSPATH == null) {
            try {
                Class<?> clazz = Class.forName(MESH_APPLICATION_CLASS);
                CodeSource codeSource = clazz.getProtectionDomain().getCodeSource();
                if (codeSource != null) {
                    Path path = new File(codeSource.getLocation().getPath()).toPath();
                    if (path.getFileName().toString().endsWith(".jar") && Files.isRegularFile(path)) {
                        MESH_JAR_PATH = path;
                    } else if (Files.isDirectory(path)) {
                        log.info("MeshApplication class found outside a jar file. Constructing classpath...");
                        MESH_CLASSPATH = Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator))
                                .filter(item -> !item.contains("test"))
                                .collect(Collectors.joining(File.pathSeparator));
                    }
                }
            } catch (ClassNotFoundException e) {
                log.info("The Mesh jar is not on the classpath. About to scan classpath directories for the jar....");
            }
        }

        if (MESH_JAR_PATH == null && MESH_CLASSPATH == null) {
            //MeshApplication could not be located on the classpath, dig deeper
            List<String> classpath = Arrays.asList(System.getProperty("java.class.path").split(File.pathSeparator));
            MESH_JAR_PATH = classpath.stream()
                    .map(Paths::get)
                    .flatMap(MeshClusterManager::streamJars)
                    .filter(path -> path.getFileName().toString().contains("mesh-app"))
                    .findFirst()
                    .orElseThrow(() -> new IllegalStateException("Cannot find the Mesh jar on the classpath: \n"
                            + classpath));
        }
        return MESH_JAR_PATH;
    }

    private static List<String> getDataStoreArgs(AbstractMeshClusterNode node) {
        List<String> args = new ArrayList<>();
        int index = 0;
        for (Path dataStore : node.getShards()) {
            args.add("-Dmesh.data-stores." + index++ + "=" + dataStore);
        }
        return args;
    }

    private static String getJavaBinary() {
        String javaBinary = "java";

        return Paths.get(System.getProperty("java.home"))
                .resolve("bin")
                .resolve(javaBinary)
                .toAbsolutePath()
                .toString();
    }

    private static Stream<Path> streamJars(Path path) {
        if (Files.isDirectory(path)) {
            try (Stream<Path> contents = Files.walk(path, 3)) {
                List<Path> jars = contents.filter(p -> p.getFileName().toString().endsWith(".jar"))
                        .collect(toList());
                return jars.stream();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }
        return path.getFileName().toString().endsWith(".jar") ? Stream.of(path) : Stream.empty();
    }

    private int selectPort(int requestedPort) {
        int port = Math.max(0, requestedPort);
        if (port > 0) {
            return port;
        }

        while (port < 65535) {
            //Find the next available free port
            try (ServerSocket socket = new ServerSocket(port)) {
                port = socket.getLocalPort();
                if (!claimedPorts.add(port)) {
                    //try the next port
                    ++port;
                } else {
                    return port;
                }
            } catch (IOException e) {
                //Could not open the socket
                ++port;
            }
        }
        throw new IllegalStateException("Could not find a free port while provisioning a Mesh node " +
                "(requested port = " + requestedPort + ")");
    }

    /**
     * A {@link AbstractMeshClusterNode} that is fully managed by the process, including provisioning, startup and shutdown.
     */
    private class DefaultManagedMeshClusterNode extends AbstractMeshClusterNode implements ManagedMeshClusterNode {

        private final boolean authenticationEnabled;
        private final Path homeDir;
        private final Object lock;
        private final ConsoleLogForwarder logForwarder;
        private final int port;

        private volatile boolean partOfCluster;
        private volatile Process process;
        private volatile Future<Void> startFuture;

        DefaultManagedMeshClusterNode(ManagedNodeBuilder builder) {
            super(builder);

            authenticationEnabled = builder.authenticationEnabled;
            homeDir = builder.homeDir == null ? Paths.get(UUID.randomUUID().toString()) : builder.homeDir;
            lock = new Object();
            logForwarder = new ConsoleLogForwarder("[MESH " + getName() + "] ", Level.WARN, System.out);
            port = builder.port;
        }

        @Override
        public void awaitReady() throws IOException, InterruptedException {
            Future<Void> future = startFuture;
            checkState(future != null, "Node " + getName() + " is not currently running");
            try {
                future.get();
            } catch (ExecutionException e) {
                Throwable cause = Throwables.getRootCause(e);
                Throwables.propagateIfPossible(cause, IOException.class);
                throw new RuntimeException("Mesh node " + getName() + " startup failed", cause);
            }
        }

        @Override
        @Nonnull
        public Path getHomeDir() {
            return homeDir;
        }

        @Override
        public int getPort() {
            return port;
        }

        @Nonnull
        @Override
        public Path getRepoDir(@Nonnull String partition, @Nonnull String hierarchyId, @Nonnull String localId) {
            requireNonNull(partition, "partition");
            requireNonNull(hierarchyId, "hierarchyId");
            requireNonNull(localId, "localId");

            return MoreFiles.resolve(getPartitionDir(partition), hierarchyId, "repos", localId);
        }

        @Override
        public boolean isAuthenticationEnabled() {
            return authenticationEnabled;
        }

        @Override
        public boolean isPartOfCluster() {
            return partOfCluster;
        }

        @Override
        public boolean isRunning() {
            Process process = this.process;
            return process != null && process.isAlive();
        }

        @Override
        public boolean joinCluster() {
            if (partOfCluster) {
                //Already part of the cluster, nothing left to do
                return true;
            }

            String id = controlPlane.registerNode(this);
            if (id == null) {
                return false;
            }
            this.id = id;
            partOfCluster = true;
            return true;
        }

        @Override
        public boolean leaveCluster(boolean stop) {
            log.debug("Removing node {} from the cluster (stop = {})", getName(), stop);
            RuntimeException exception = null;
            boolean result = false;
            if (partOfCluster) {
                try {
                    result = controlPlane.unregisterNode(this);
                    partOfCluster = false;
                } catch (RuntimeException e) {
                    exception = e;
                }
            }

            if (stop) {
                try {
                    stop();
                } catch (RuntimeException e) {
                    if (exception == null) {
                        exception = e;
                    } else {
                        exception.addSuppressed(e);
                    }
                }
            }
            if (exception != null) {
                throw exception;
            }
            return result;
        }

        @Override
        public void reset() {
            getShards().forEach(this::resetShard);
        }

        @Override
        public void setLogLevel(@Nonnull Level level) {
            logForwarder.setLevel(level);
        }

        @Nonnull
        @Override
        public Future<Void> start() {
            synchronized (lock) {
                if (startFuture == null) {
                    startFuture = executor.submit(new StartNodeTask(this, logForwarder));
                    return startFuture;
                }
                throw new IllegalStateException("The node process is already running");
            }
        }

        @Override
        public void stop() {
            synchronized (lock) {
                if (process != null && process.isAlive()) {
                    process.destroy();
                }
                process = null;
                //clear out the startFuture so we can restart it later
                startFuture = null;
                //make the listening port current bound to be available for use again if we have ports
                if (availableListenPorts != null) {
                    Integer freedPort = boundListenPorts.remove(this);
                    if (freedPort != null) {
                        availableListenPorts.add(freedPort);
                    }
                }
            }
        }

        @Override
        public <T> T whileOffline(Callable<T> action) throws Exception {
            //Rather than actually shutting down the node and restarting it, which would be slow, we simply
            //remove it from the cluster (and the topology), and then put it back
            synchronized (lock) {
                leaveCluster(false);
                try {
                    return action.call();
                } finally {
                    joinCluster();
                }
            }
        }

        void resetShard(Path shardDir) {
            Path absoluteShardDir = shardDir.isAbsolute() ? shardDir : homeDir.resolve(shardDir);
            deleteAndRecreate(absoluteShardDir.resolve("data"));
            deleteAndRecreate(absoluteShardDir.resolve("hook-scripts"));
        }

        private void deleteAndRecreate(Path path) {
            //Retry the delete a few times with a sleep mixed in, in case something holds files open
            for (int attempt = 0; attempt < 3; ++attempt) {
                //TODO: when we support attaching and detaching shards, this should just detach the shard and attach
                //a freshly created shard instead.
                try {
                    MoreFiles.deleteRecursively(path);
                    Files.createDirectories(path);
                    return;
                } catch (IOException e) {
                    if (attempt == 2) {
                        log.warn("Failed to delete and recreate {}", path, e);
                    } else {
                        try {
                            Thread.sleep(500);
                        } catch (InterruptedException interruptedException) {
                            Thread.currentThread().interrupt();
                            throw new UncheckedIOException(e);
                        }
                    }
                }
            }
        }

        private Path getPartitionDir(String partitionId) {
            Path relPartitionDir = Paths.get("data", partitionId);

            Path firstCandidate = null;
            for (Path shardDir : getShards()) {
                Path candidate = homeDir.resolve(shardDir).resolve(relPartitionDir);
                if (MoreFiles.dirExists(candidate)) {
                    return candidate;
                }
                if (firstCandidate == null) {
                    firstCandidate = candidate;
                }
            }
            return firstCandidate;
        }
    }

    private class ManagedNodeBuilder extends AbstractMeshClusterNode.AbstractBuilder<ManagedNodeBuilder, DefaultManagedMeshClusterNode> {

        private boolean authenticationEnabled;
        private Path homeDir;
        private int port;

        ManagedNodeBuilder(@Nonnull String name) {
            super(name);
        }

        @Nonnull
        public ManagedNodeBuilder authenticationEnabled(boolean value) {
            authenticationEnabled = value;
            return self();
        }

        @Nonnull
        @Override
        public DefaultManagedMeshClusterNode build() {
            return new DefaultManagedMeshClusterNode(this);
        }

        @Nonnull
        public ManagedNodeBuilder homeDir(@Nonnull Path value) {
            homeDir = requireNonNull(value, "homeDir");
            return self();
        }

        @Nonnull
        public ManagedNodeBuilder port(int value) {
            port = value;
            return self();
        }

        @Override
        protected ManagedNodeBuilder self() {
            return this;
        }
    }

    private class StartNodeTask implements Callable<Void> {

        private final IoConsumer<ByteBuffer> logConsumer;
        private final DefaultManagedMeshClusterNode node;

        StartNodeTask(DefaultManagedMeshClusterNode node, IoConsumer<ByteBuffer> logConsumer) {
            this.logConsumer = logConsumer;
            this.node = node;
        }

        @Override
        public Void call() throws IOException {
            //Configure the same file encoding for the sidecar as has been configured for the Bitbucket process
            String encoding = Charset.defaultCharset().name();

            List<String> args = new ArrayList<>();
            args.add(getJavaBinary());
            args.addAll(getDebuggerArgs());
            args.add("-Xms256m");
            args.add("-Xmx256m");
            args.add("-XX:+UseG1GC");
            args.add("-Dfile.encoding=" + encoding);
            args.add("-Dsun.jnu.encoding=" + encoding);
            args.add("-Dgrpc.server.address"); //Force the Mesh node to bind to localhost only
            args.add("-Dgrpc.server.port=" + node.getPort());
            args.add("-Dmesh.home=" + node.getHomeDir());
            args.addAll(getDataStoreArgs(node));
            args.add("-Dauthentication.enabled=" + node.isAuthenticationEnabled());
            args.add("-Dlogging.logger.com.atlassian.bitbucket.mesh=DEBUG");
            //If a custom git version has been configured, use that
            String gitExecutable = System.getProperty("git.path.executable");
            if (isBlank(gitExecutable)) {
                gitExecutable = System.getProperty("plugin.bitbucket-git.path.executable");
            }
            if (isNotBlank(gitExecutable)) {
                args.add("-Dgit.path.executable=" + gitExecutable);
            }
            if (logConsumer != null) {
                args.add("-Dlogging.console=true");
            }

            //Prefer starting Mesh from the classpath if possible. It ensures that the latest version of Mesh
            //is used, without jumping through Maven hoops to make the full jar available somewhere.
            if (MESH_CLASSPATH == null) {
                args.add("-jar");
                args.add(meshJarPath.toAbsolutePath().toString());
            } else {
                args.add("-classpath");
                args.add(MESH_CLASSPATH);
                args.add(MESH_APPLICATION_CLASS);
            }

            Process process = new ProcessBuilder()
                    .command(args)
                    .directory(node.getHomeDir().toFile()) //Set working directory to the system's home directory
                    .redirectErrorStream(true) //Redirect stderr to stdout; nothing should be written to it
                    .start();
            if (logConsumer != null) {
                consoleLogProcessor.add(process, logConsumer);
            }

            long start = System.currentTimeMillis();
            //Wait up to 1 minute for the node is to bind to the gRPC port
            long deadline = start + TimeUnit.SECONDS.toMillis(60);
            boolean ready = false;
            try {
                while (System.currentTimeMillis() < deadline) {
                    if (!process.isAlive()) {
                        throw new IllegalStateException("Failed to connect node " + node.getName() +
                                " (exit value = " + process.exitValue() + ", stderr = " +
                                IOUtils.toString(process.getErrorStream(), StandardCharsets.UTF_8) + ")");
                    }

                    ManagedChannel channel = null;
                    try {
                        channel = controlPlane.openChannel(node);
                        CapabilityServiceGrpc.newBlockingStub(channel)
                                .getCapabilities(RpcGetCapabilitiesRequest.getDefaultInstance());

                        long duration = System.currentTimeMillis() - start;
                        log.info("Mesh node {} started up in {} seconds (port = {})",
                                node.getName(), duration / 1000.0, node.getPort());
                        node.process = process;
                        ready = true;

                        return null;
                    } catch (Exception e) {
                        try {
                            long remaining = Math.max(0, deadline - System.currentTimeMillis());
                            Thread.sleep(Math.min(250, remaining));
                        } catch (InterruptedException interruptedException) {
                            Thread.currentThread().interrupt();
                        }
                    } finally {
                        if (channel != null) {
                            channel.shutdownNow();
                        }
                    }
                }
            } finally {
                if (!ready) {
                    process.destroy();
                }
            }
            //We've timed out waiting!
            throw new IllegalStateException("Timed out waiting for Mesh node " + node.getName() + " to start");
        }

        private List<String> getDebuggerArgs() {
            if (ManagementFactory.getRuntimeMXBean().getInputArguments().stream()
                    .anyMatch(arg -> arg.startsWith("-agentlib:jdwp="))) {

                if (availableListenPorts != null) {
                    try {
                        Integer port = availableListenPorts.poll(5, TimeUnit.SECONDS);
                        if (port != null) {
                            boundListenPorts.put(node, port);
                            log.info("{}: Starting in debug mode, connecting to port {}", node.getId(), port);
                            return Collections.singletonList(
                                    "-agentlib:jdwp=transport=dt_socket,server=n,address=localhost:" + port +
                                            ",suspend=y");
                        }
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        return Collections.emptyList();
                    }
                }
                String address;
                try (ServerSocket socket = new ServerSocket(0)) {
                    address = ",address=" + socket.getLocalPort();
                    log.info("{}: Starting in debug mode, listening on port {}", node.getId(), socket.getLocalPort());
                } catch (IOException e) {
                    address = "";
                    log.warn("{}: Starting in debug mode, listening on a random port", node.getId());
                }

                return Collections.singletonList("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n" + address);
            }

            return Collections.emptyList();
        }
    }
}
