/*
 * Copyright 2006-2021 DLR, Germany
 * 
 * SPDX-License-Identifier: EPL-1.0
 * 
 * https://rcenvironment.de/
 */
package de.rcenvironment.core.communication.testutils.templates;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeoutException;

import org.junit.BeforeClass;
import org.junit.Test;

import de.rcenvironment.core.communication.common.InstanceNodeSessionId;
import de.rcenvironment.core.communication.configuration.NodeConfigurationService;
import de.rcenvironment.core.communication.connection.api.ConnectionSetup;
import de.rcenvironment.core.communication.connection.api.ConnectionSetupService;
import de.rcenvironment.core.communication.connection.api.ConnectionSetupState;
import de.rcenvironment.core.communication.connection.api.DisconnectReason;
import de.rcenvironment.core.communication.connection.impl.ConnectionSetupServiceImpl;
import de.rcenvironment.core.communication.model.NetworkResponse;
import de.rcenvironment.core.communication.nodeproperties.NodePropertyConstants;
import de.rcenvironment.core.communication.protocol.ProtocolConstants;
import de.rcenvironment.core.communication.routing.internal.NetworkFormatter;
import de.rcenvironment.core.communication.testutils.AbstractVirtualInstanceTest;
import de.rcenvironment.core.communication.testutils.ConnectionSetupStateTracker;
import de.rcenvironment.core.communication.testutils.TestNetworkRequestHandler;
import de.rcenvironment.core.communication.testutils.VirtualInstance;
import de.rcenvironment.core.communication.testutils.VirtualInstanceGroup;
import de.rcenvironment.core.communication.transport.virtual.testutils.VirtualTopology;
import de.rcenvironment.core.configuration.ConfigurationService;
import de.rcenvironment.core.toolkitbridge.transitional.ConcurrencyUtils;
import de.rcenvironment.core.utils.testing.CommonTestOptions;
import de.rcenvironment.toolkit.modules.concurrency.api.AsyncTaskService;

/**
 * Base class providing tests that can operate using duplex as well as non-duplex transports. A common use case is setting up a topology
 * where are topology links are logially bidirectional. In this case, the test code must adapt as this bidirectional linkage is
 * automatically achieved when using a duplex transport, but must be explicitly wired when using a non-duplex transport.
 * 
 * @author Robert Mischke
 * @author Phillip Kroll
 */
public abstract class AbstractSwitchableVirtualInstanceTest extends AbstractVirtualInstanceTest {

    private static final String DEFAULT_SERVER_NODE_ID = "server";

    /**
     * A short wait time for connect/disconnect actions to complete; fairly long to support real transports in stress-test setups.
     */
    private static final int CONNECTION_OPERATION_WAIT_MSEC = 5000;

    // simulated number of clients for concurrent connection setups testing
    private static final int CONCURRENT_CS_TEST_NUM_CLIENTS = CommonTestOptions.selectStandardOrExtendedValue(3, 20);

    private static final int CONCURRENT_CS_TEST_TIMEOUT_MSEC = 10000;

    private static final String MESSAGE_INSTANCES_DID_NOT_CONVERGE_AT_ITERATION = "Instances did not converge at iteration index ";

    private static final int TEST_SIZE = CommonTestOptions.selectStandardOrExtendedValue(3, 10);

    protected final Random randomGenerator = new Random();

    /**
     * @throws Exception on uncaught exceptions
     */
    @BeforeClass
    // TODO transitional; rework this
    public static void setTestParameters() throws Exception {
        testSize = TEST_SIZE;
    }

    @Override
    protected void setupInstances(int numNodes, boolean useDuplexTransport, boolean startInstances) throws InterruptedException {
        super.setupInstances(numNodes, useDuplexTransport, startInstances);
        // this causes all connect() calls to explicitly create a connection for each direction by default
        testTopology.setConnectBothDirectionsByDefaultFlag(!usingDuplexTransport);
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testClientServerBidirectionalMessaging() throws Exception {

        setupInstances(2, usingDuplexTransport, true);

        VirtualInstance client = testTopology.getInstance(0);
        VirtualInstance server = testTopology.getInstance(1);
        prepareWaitForNextMessage();
        testTopology.connect(0, 1);
        waitForNextMessage();
        waitForNetworkSilence();

        NetworkResponse serverResponse =
            client.performRoutedRequest("c2s", ProtocolConstants.VALUE_MESSAGE_TYPE_TEST, server.getInstanceNodeSessionId());
        assertNotNull("C2S communication failed", serverResponse);
        assertTrue("C2S communication failed: " + NetworkFormatter.networkResponseToString(serverResponse), serverResponse.isSuccess());
        assertEquals(TestNetworkRequestHandler.getTestResponse("c2s", server.getInstanceNodeSessionId()),
            serverResponse.getDeserializedContent());
        NetworkResponse clientResponse =
            server.performRoutedRequest("s2c", ProtocolConstants.VALUE_MESSAGE_TYPE_TEST, client.getInstanceNodeSessionId());
        assertNotNull("S2C communication failed", clientResponse);
        assertTrue("S2C communication failed", clientResponse.isSuccess());
        assertEquals(TestNetworkRequestHandler.getTestResponse("s2c", client.getInstanceNodeSessionId()),
            clientResponse.getDeserializedContent());

        // Systemx.out.println(NetworkFormatter.summary(client.getTopologyMap()));
        // Systemx.out.println(NetworkFormatter.summary(server.getTopologyMap()));

        prepareWaitForNextMessage();
        testTopology.getAsGroup().shutDown();
        waitForNextMessage();
        waitForNetworkSilence();
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testIterativelyGrowingLinearNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        for (int i = 0; i < allInstances.length - 1; i++) {
            logIteration(i);
            int newInstanceIndex = i + 1;

            prepareWaitForNextMessage();
            testTopology.connect(i, newInstanceIndex);
            waitForNextMessage();
            waitForNetworkSilence();

            // note: the third parameter in Arrays.copyOfRange() is exclusive and must be "last + 1"
            assertTrue(MESSAGE_INSTANCES_DID_NOT_CONVERGE_AT_ITERATION + i,
                instanceUtils.allInstancesHaveSameRawNetworkGraph(Arrays.copyOfRange(allInstances, 0, newInstanceIndex + 1)));
        }
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testConcurrentlyGrowingLinearNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        prepareWaitForNextMessage();
        for (int i = 0; i < allInstances.length - 1; i++) {
            testTopology.connect(i, i + 1);
        }
        waitForNextMessage();
        waitForNetworkSilence();

        assertTrue(testTopology.allInstancesConverged());
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testIterativelyGrowingRandomAcyclicNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        Random random = new Random();
        for (int i = 0; i < allInstances.length - 1; i++) {
            logIteration(i);
            int newInstanceIndex = i + 1;
            int connectedInstanceIndex = random.nextInt(newInstanceIndex); // 0..newInstanceIndex-1
            prepareWaitForNextMessage();
            testTopology.connect(connectedInstanceIndex, newInstanceIndex);
            waitForNextMessage();
            waitForNetworkSilence();

            // note: the third parameter in Arrays.copyOfRange() is exclusive and must be "last + 1"
            assertTrue(MESSAGE_INSTANCES_DID_NOT_CONVERGE_AT_ITERATION + i,
                instanceUtils.allInstancesHaveSameRawNetworkGraph(Arrays.copyOfRange(allInstances, 0, newInstanceIndex + 1)));
        }
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testIterativelyGrowingRandomCyclicNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        Random random = new Random();
        for (int i = 0; i < allInstances.length - 1; i++) {
            logIteration(i);
            int newInstanceIndex = i + 1;
            int connectedInstanceIndex1 = random.nextInt(newInstanceIndex); // 0..newInstanceIndex-1
            prepareWaitForNextMessage();
            testTopology.connect(connectedInstanceIndex1, newInstanceIndex);
            // connect to two existing nodes per iteration to create cycles
            if (i >= 3) {
                // do not start at i=2, otherwise the starting graph will always be the same
                int connectedInstanceIndex2;
                do {
                    connectedInstanceIndex2 = random.nextInt(newInstanceIndex); // 0..i
                } while (connectedInstanceIndex2 == connectedInstanceIndex1);
                testTopology.connect(connectedInstanceIndex2, newInstanceIndex);
            }
            waitForNextMessage();
            waitForNetworkSilence();

            // note: the third parameter in Arrays.copyOfRange() is exclusive and must be "last + 1"
            assertTrue(MESSAGE_INSTANCES_DID_NOT_CONVERGE_AT_ITERATION + i,
                instanceUtils.allInstancesHaveSameRawNetworkGraph(Arrays.copyOfRange(allInstances, 0, newInstanceIndex + 1)));
        }
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testConcurrentlyGrowingRandomAcyclicNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        prepareWaitForNextMessage();
        Random random = new Random();
        for (int i = 0; i < allInstances.length - 1; i++) {
            logIteration(i);
            int newInstanceIndex = i + 1;
            int connectedInstanceIndex = random.nextInt(newInstanceIndex); // 0..i
            testTopology.connect(connectedInstanceIndex, newInstanceIndex);
        }
        waitForNextMessage();
        waitForNetworkSilence();
        assertTrue(ERROR_MSG_INSTANCES_DID_NOT_CONVERGE, instanceUtils.allInstancesHaveSameRawNetworkGraph(allInstances));
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testConcurrentlyGrowingRandomCyclicNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        prepareWaitForNextMessage();
        Random random = new Random();
        for (int i = 0; i < allInstances.length - 1; i++) {
            logIteration(i);
            int newInstanceIndex = i + 1;
            int connectedInstanceIndex1 = random.nextInt(newInstanceIndex); // 0..i
            testTopology.connect(connectedInstanceIndex1, newInstanceIndex);
            // connect to two existing nodes per iteration to create cycles
            if (i >= 3) {
                // do not start at i=2, otherwise the starting graph will always be the same
                int connectedInstanceIndex2;
                do {
                    connectedInstanceIndex2 = random.nextInt(newInstanceIndex); // 0..i
                } while (connectedInstanceIndex2 == connectedInstanceIndex1);
                testTopology.connect(connectedInstanceIndex2, newInstanceIndex);
            }
        }
        waitForNextMessage();
        waitForNetworkSilence();
        assertTrue(ERROR_MSG_INSTANCES_DID_NOT_CONVERGE, instanceUtils.allInstancesHaveSameRawNetworkGraph(allInstances));
    }

    /**
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testNodePropertiesInConcurrentlyGrowingRandomNetwork() throws Exception {

        setupInstances(TEST_SIZE, usingDuplexTransport, true);

        prepareWaitForNextMessage();
        Random random = new Random();
        for (int i = 0; i < allInstances.length - 1; i++) {
            logIteration(i);
            int newInstanceIndex = i + 1;
            int connectedInstanceIndex = random.nextInt(newInstanceIndex); // 0..i
            testTopology.connect(connectedInstanceIndex, newInstanceIndex);
        }
        waitForNextMessage();
        waitForNetworkSilence();
        assertTrue(ERROR_MSG_INSTANCES_DID_NOT_CONVERGE, instanceUtils.allInstancesHaveSameRawNetworkGraph(allInstances));

        // check for consistently converged properties on all nodes
        for (VirtualInstance vi : allInstances) {
            Map<InstanceNodeSessionId, Map<String, String>> allNodeProperties = vi.getNodePropertiesService().getAllNodeProperties();
            assertEquals(allInstances.length, allNodeProperties.size());
            // each node should see proper "nodeId" properties fields for each node
            for (VirtualInstance vi2 : allInstances) {
                InstanceNodeSessionId nodeId = vi2.getInstanceNodeSessionId();
                Map<String, String> nodeProperties = allNodeProperties.get(nodeId);
                assertNotNull("No metadata for node " + nodeId + " at " + vi.getInstanceNodeSessionId(), nodeProperties);
                assertEquals(nodeId.getInstanceNodeSessionIdString(), nodeProperties.get(NodePropertyConstants.KEY_NODE_ID));
            }
        }

        // inject a metadata value at each node
        String insertionTestMetadataKey = "insertionTest";
        String insertionTestDataSuffix = ".inserted";
        prepareWaitForNextMessage();
        for (VirtualInstance vi : allInstances) {
            vi.getNodePropertiesService().addOrUpdateLocalNodeProperty(insertionTestMetadataKey,
                vi.getInstanceNodeSessionId().getInstanceNodeSessionIdString() + insertionTestDataSuffix);
        }
        waitForNextMessage();
        waitForNetworkSilence();

        // check for consistent *injected* properties on all nodes
        for (VirtualInstance vi : allInstances) {
            Map<InstanceNodeSessionId, Map<String, String>> allNodeProperties = vi.getNodePropertiesService().getAllNodeProperties();
            assertEquals(allInstances.length, allNodeProperties.size());
            // each node should see proper "nodeId" property fields for each node
            for (VirtualInstance vi2 : allInstances) {
                InstanceNodeSessionId nodeId = vi2.getInstanceNodeSessionId();
                assertEquals(nodeId.getInstanceNodeSessionIdString() + insertionTestDataSuffix,
                    allNodeProperties.get(nodeId).get(insertionTestMetadataKey));
            }
        }

        // print debug output
        // Map<NodeIdentifier, Map<String, String>> allNodeProperties =
        // allInstances[0].getNodePropertiesService().getAllNodeProperties();
        // Thread.sleep(500);
        // for (VirtualInstance vi : allInstances) {
        // NodeIdentifier nodeId = vi.getNodeId();
        // log.info("Metadata knowledge about " + nodeId + ": " + allNodeProperties.get(nodeId));
        // }
        // Thread.sleep(500);
    }

    /**
     * Tests for proper {@link ConnectionSetupService} behaviour.
     * 
     * @throws Exception on uncaught Exceptions
     */
    @Test(timeout = CONCURRENT_CS_TEST_TIMEOUT_MSEC)
    public void testConcurrentConnectionSetupBehaviour() throws Exception {
        final VirtualInstance server = new VirtualInstance(DEFAULT_SERVER_NODE_ID, false); // no relay
        server.registerNetworkTransportProvider(transportProvider);
        server.addServerConfigurationEntry(contactPointGenerator.createContactPoint());
        server.start();

        ConnectionSetupService serverConnectionSetupService = server.getConnectionSetupService();
        assertEquals(0, serverConnectionSetupService.getAllConnectionSetups().size());

        final List<RuntimeException> exceptions = Collections.synchronizedList(new ArrayList<RuntimeException>());

        final CountDownLatch phase1Cdl = new CountDownLatch(CONCURRENT_CS_TEST_NUM_CLIENTS);
        final CountDownLatch serverShutdownCdl = new CountDownLatch(1);
        final CountDownLatch phase2Cdl = new CountDownLatch(CONCURRENT_CS_TEST_NUM_CLIENTS);

        AsyncTaskService threadPool = ConcurrencyUtils.getAsyncTaskService();
        for (int i = 1; i <= CONCURRENT_CS_TEST_NUM_CLIENTS; i++) {
            final int i2 = i;
            threadPool.execute(new Runnable() {

                @Override
                public void run() {
                    log.debug("Starting thread " + i2);
                    try {
                        VirtualInstance client = new VirtualInstance("client-" + i2);
                        client.registerNetworkTransportProvider(transportProvider);
                        client.start();
                        log.debug("Created client " + client.getInstanceNodeSessionId());
                        ConnectionSetupServiceImpl clientConnectionSetupService =
                            (ConnectionSetupServiceImpl) client.getConnectionSetupService();

                        assertEquals(0, clientConnectionSetupService.getAllConnectionSetups().size());
                        ConnectionSetup setupC2S =
                            clientConnectionSetupService.createConnectionSetup(server.getDefaultContactPoint(), "test-cs-" + i2, true);
                        assertEquals(1, clientConnectionSetupService.getAllConnectionSetups().size());

                        ConnectionSetupStateTracker tracker = new ConnectionSetupStateTracker(setupC2S);
                        clientConnectionSetupService.addConnectionSetupListener(tracker);

                        testConnectionSetupBehaviourBeforePotentialServerShutdown(client, server, clientConnectionSetupService,
                            setupC2S, tracker);
                        phase1Cdl.countDown();

                        // if this is not a duplex test, verify direct connection shutdown
                        if (usingDuplexTransport) {
                            log.debug("Waiting for server shutdown...");
                            serverShutdownCdl.await();
                        } else {
                            setupC2S.signalStopIntent();
                            tracker.awaitAndExpect(ConnectionSetupState.DISCONNECTING, CONNECTION_OPERATION_WAIT_MSEC);
                        }

                        testConnectionSetupBehaviourAfterPotentialServerShutdown(client, server, clientConnectionSetupService,
                            setupC2S, tracker);
                        client.shutDown();
                        phase2Cdl.countDown();
                    } catch (InterruptedException | AssertionError | TimeoutException e) {
                        onException(e);
                    }
                }

                private void onException(Throwable e) {
                    exceptions.add(new RuntimeException(e));
                    // let it terminate quickly; it will fail anyway
                    phase1Cdl.countDown();
                    phase2Cdl.countDown();
                }
            });
        }

        phase1Cdl.await();

        assertEquals(0, serverConnectionSetupService.getAllConnectionSetups().size());

        if (usingDuplexTransport) {
            // if this is a duplex test, verify indirect/remote connection shutdown
            server.shutDown();
            serverShutdownCdl.countDown();
        }
        phase2Cdl.await();
        if (!usingDuplexTransport) {
            server.shutDown();
        }

        assertEquals(0, serverConnectionSetupService.getAllConnectionSetups().size());

        if (!exceptions.isEmpty()) {
            for (Throwable e : exceptions) {
                log.error("Async test exception", e);
            }
            fail("Async exceptions have occurred (see log)");
        }
    }

    private void testConnectionSetupBehaviourBeforePotentialServerShutdown(VirtualInstance client, VirtualInstance server,
        ConnectionSetupServiceImpl clientConnectionSetupService, ConnectionSetup setupC2S, ConnectionSetupStateTracker tracker)
        throws InterruptedException,
        AssertionError, TimeoutException {

        // check initial, unconnected state
        assertEquals(1, clientConnectionSetupService.getAllConnectionSetups().size());
        assertEquals(setupC2S, clientConnectionSetupService.getAllConnectionSetups().iterator().next());
        assertEquals(ConnectionSetupState.DISCONNECTED, setupC2S.getState());
        assertNull(setupC2S.getCurrentChannelId());
        assertNull(setupC2S.getLastChannelId());

        setupC2S.signalStartIntent();

        tracker.awaitAndExpect(ConnectionSetupState.CONNECTING, CONNECTION_OPERATION_WAIT_MSEC);
        tracker.awaitAndExpect(ConnectionSetupState.CONNECTED, CONNECTION_OPERATION_WAIT_MSEC);

        assertEquals(1, clientConnectionSetupService.getAllConnectionSetups().size());
        assertEquals(ConnectionSetupState.CONNECTED, setupC2S.getState());
        assertNotNull(setupC2S.getCurrentChannelId());
        assertNotNull(setupC2S.getLastChannelId());

        String firstChannelId = setupC2S.getCurrentChannelId();

        setupC2S.signalStopIntent();

        tracker.awaitAndExpect(ConnectionSetupState.DISCONNECTING, CONNECTION_OPERATION_WAIT_MSEC);
        tracker.awaitAndExpect(ConnectionSetupState.DISCONNECTED, CONNECTION_OPERATION_WAIT_MSEC);

        assertEquals(ConnectionSetupState.DISCONNECTED, setupC2S.getState());
        assertEquals(DisconnectReason.ACTIVE_SHUTDOWN, setupC2S.getDisconnectReason());
        assertNull(setupC2S.getCurrentChannelId());
        // last channel id should still be present, e.g. for debugging
        assertNotNull(setupC2S.getLastChannelId());
        assertTrue(setupC2S.getLastChannelId().equals(firstChannelId));

        // reconnect
        setupC2S.signalStartIntent();

        tracker.awaitAndExpect(ConnectionSetupState.CONNECTING, CONNECTION_OPERATION_WAIT_MSEC);
        tracker.awaitAndExpect(ConnectionSetupState.CONNECTED, CONNECTION_OPERATION_WAIT_MSEC);

        assertEquals(ConnectionSetupState.CONNECTED, setupC2S.getState());
        assertNotNull(setupC2S.getCurrentChannelId());
        assertNotNull(setupC2S.getLastChannelId());
        // ensure the connection setup received a new channel id after reconnect
        assertFalse(setupC2S.getLastChannelId().equals(firstChannelId));
    }

    private void testConnectionSetupBehaviourAfterPotentialServerShutdown(VirtualInstance client,
        VirtualInstance server, ConnectionSetupServiceImpl clientConnectionSetupService, ConnectionSetup setupC2S,
        ConnectionSetupStateTracker tracker) throws InterruptedException, AssertionError, TimeoutException {

        tracker.awaitAndExpect(ConnectionSetupState.DISCONNECTED, CONNECTION_OPERATION_WAIT_MSEC);
        assertEquals(ConnectionSetupState.DISCONNECTED, setupC2S.getState());
        assertNull(setupC2S.getCurrentChannelId());
        assertNotNull(setupC2S.getLastChannelId());

        if (usingDuplexTransport) {
            assertEquals(DisconnectReason.REMOTE_SHUTDOWN, setupC2S.getDisconnectReason());
        } else {
            assertEquals(DisconnectReason.ACTIVE_SHUTDOWN, setupC2S.getDisconnectReason());
        }
    }

    /**
     * Tests the effect of the {@link NodeConfigurationService#isRelay()} setting (which is read from
     * {@link ConfigurationService#getIsRelay()} in live instances).
     * 
     * @throws Exception on uncaught exceptions
     */
    @Test
    public void testRelayBehaviour() throws Exception {
        // create instances manually to control the "is relay" settings
        VirtualInstance c1 = new VirtualInstance("c1", false); // actually irrelevant
        VirtualInstance c2 = new VirtualInstance("c2", false);
        VirtualInstance c3 = new VirtualInstance("c3", false); // actually irrelevant
        VirtualInstance s1 = new VirtualInstance("s1", true);
        VirtualInstance s2 = new VirtualInstance("s2", true);

        VirtualInstanceGroup allNodes = new VirtualInstanceGroup(c1, c2, c3, s1, s2);
        allInstances = allNodes.toArray();
        for (VirtualInstance instance : allInstances) {
            instance.addServerConfigurationEntry(contactPointGenerator.createContactPoint());
        }
        allNodes.registerNetworkTransportProvider(transportProvider);
        allNodes.addNetworkTrafficListener(getGlobalTrafficListener());
        testTopology = new VirtualTopology(allInstances);
        testTopology.setConnectBothDirectionsByDefaultFlag(!usingDuplexTransport);
        allNodes.start();

        // connect to an "M" shape
        prepareWaitForNextMessage();
        testTopology.connect(0, 3); // c1-s1
        testTopology.connect(1, 3); // c2-s1
        testTopology.connect(1, 4); // c2-s2
        testTopology.connect(2, 4); // c3-s2
        waitForNextMessage();
        waitForNetworkSilence();

        for (VirtualInstance instance : allInstances) {
            log.debug(NetworkFormatter.networkGraphToGraphviz(instance.getRawNetworkGraph(), true));
        }

        VirtualInstance[][] expectedNonTrivialVisibilities = new VirtualInstance[][] {
            { c1, s1 },
            { c1, c2 },
            { c2, c1 },
            { c2, s1 },
            { c2, s2 },
            { c2, c3 },
            { c3, c2 },
            { c3, s2 },
            { s1, c1 },
            { s1, c2 },
            { s2, c2 },
            { s2, c3 }
        };

        // create visibility matrix
        Map<VirtualInstance, Map<VirtualInstance, Boolean>> visibilityMatrix =
            new HashMap<VirtualInstance, Map<VirtualInstance, Boolean>>();
        for (VirtualInstance i1 : allInstances) {
            Map<VirtualInstance, Boolean> singleInstanceVisMap = new HashMap<VirtualInstance, Boolean>();
            visibilityMatrix.put(i1, singleInstanceVisMap);
            for (VirtualInstance i2 : allInstances) {
                // initialize with trivial visibility
                singleInstanceVisMap.put(i2, (i1 == i2));
            }
        }
        // add non-trivial visibilities
        for (VirtualInstance[] entry : expectedNonTrivialVisibilities) {
            visibilityMatrix.get(entry[0]).put(entry[1], Boolean.TRUE);
        }

        // test network graph visibility
        for (VirtualInstance i1 : allInstances) {
            for (VirtualInstance i2 : allInstances) {
                boolean expected = visibilityMatrix.get(i1).get(i2);
                assertEquals("Raw network graph visibility: " + i1.getInstanceNodeSessionId() + " ->" + i2.getInstanceNodeSessionId(),
                    expected, i1.getRawNetworkGraph().getNodeIds().contains(i2.getInstanceNodeSessionId()));
            }
        }

        // verify expected visible node counts (as a cross-check)
        Set<InstanceNodeSessionId> c1VisibleNodes = c1.getRawNetworkGraph().getNodeIds();
        Set<InstanceNodeSessionId> c2VisibleNodes = c2.getRawNetworkGraph().getNodeIds();
        Set<InstanceNodeSessionId> c3VisibleNodes = c3.getRawNetworkGraph().getNodeIds();
        Set<InstanceNodeSessionId> s1VisibleNodes = s1.getRawNetworkGraph().getNodeIds();
        Set<InstanceNodeSessionId> s2VisibleNodes = s2.getRawNetworkGraph().getNodeIds();
        assertEquals(3, c1VisibleNodes.size());
        assertEquals(3, s1VisibleNodes.size());
        assertEquals(5, c2VisibleNodes.size());
        assertEquals(3, c3VisibleNodes.size());
        assertEquals(3, s2VisibleNodes.size());

        // test messaging
        for (VirtualInstance i1 : allInstances) {
            for (VirtualInstance i2 : allInstances) {
                // local-to-local routing is not allowed, so do not test it
                if (i1 == i2) {
                    continue;
                }
                boolean expected = visibilityMatrix.get(i1).get(i2);
                // perform a standard routed request; while this is a good test of the "positive" allowed routing paths,
                // the "negative" (forbidden) paths are not really tested as the network graph should simply not contain
                // the target node, which was already tested above - misc_ro
                // TODO add test that non-relays actually refuse to forward requests
                NetworkResponse response =
                    i1.performRoutedRequest("test", ProtocolConstants.VALUE_MESSAGE_TYPE_TEST, i2.getInstanceNodeSessionId());
                boolean success = response.isSuccess();
                assertEquals("Messaging result: " + i1.getInstanceNodeSessionId() + " ->" + i2.getInstanceNodeSessionId(), expected,
                    success);
            }
        }

        // test node property visibilty;
        for (VirtualInstance i1 : allInstances) {
            Map<InstanceNodeSessionId, Map<String, String>> allNodeProperties = i1.getNodePropertiesService().getAllNodeProperties();
            assertEquals("Node " + i1 + " knows a different number of nodes with published properties "
                + "than its total number known network nodes", i1.getRawNetworkGraph().getNodeCount(), allNodeProperties.size());
            // check that all node id properties (simplest differing one to test) are actually visible
            for (Entry<InstanceNodeSessionId, Map<String, String>> e : allNodeProperties.entrySet()) {
                InstanceNodeSessionId targetNodeId = e.getKey();
                Map<String, String> targetProperties = e.getValue();
                assertEquals(targetNodeId.getInstanceNodeSessionIdString(), targetProperties.get(NodePropertyConstants.KEY_NODE_ID));
            }
        }

        prepareWaitForNextMessage();
        allNodes.shutDown();
        waitForNextMessage();
        waitForNetworkSilence();
    }

    private void logIteration(int i) {
        log.debug("Starting iteration " + i);
    }

}
