/*
 * Copyright 2006-2019 DLR, Germany
 * 
 * SPDX-License-Identifier: EPL-1.0
 * 
 * https://rcenvironment.de/
 */

package de.rcenvironment.core.communication.testutils;

import java.io.Serializable;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import de.rcenvironment.core.communication.api.CommunicationService;
import de.rcenvironment.core.communication.channel.MessageChannelLifecycleListener;
import de.rcenvironment.core.communication.channel.MessageChannelService;
import de.rcenvironment.core.communication.channel.MessageChannelTrafficListener;
import de.rcenvironment.core.communication.common.CommunicationException;
import de.rcenvironment.core.communication.common.InstanceNodeId;
import de.rcenvironment.core.communication.common.InstanceNodeSessionId;
import de.rcenvironment.core.communication.common.NetworkGraph;
import de.rcenvironment.core.communication.common.NetworkGraphLink;
import de.rcenvironment.core.communication.common.SerializationException;
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.management.CommunicationManagementService;
import de.rcenvironment.core.communication.model.NetworkContactPoint;
import de.rcenvironment.core.communication.model.NetworkResponse;
import de.rcenvironment.core.communication.nodeproperties.NodePropertiesService;
import de.rcenvironment.core.communication.nodeproperties.NodePropertyConstants;
import de.rcenvironment.core.communication.protocol.ProtocolConstants;
import de.rcenvironment.core.communication.routing.MessageRoutingService;
import de.rcenvironment.core.communication.routing.NetworkRoutingService;
import de.rcenvironment.core.communication.routing.internal.NetworkFormatter;
import de.rcenvironment.core.communication.routing.internal.NetworkRoutingServiceImpl;
import de.rcenvironment.core.communication.spi.NetworkTopologyChangeListenerAdapter;
import de.rcenvironment.core.communication.transport.spi.NetworkTransportProvider;
import de.rcenvironment.core.communication.utils.MessageUtils;
import de.rcenvironment.core.utils.common.StringUtils;

/**
 * Provides a simulated/"virtual" node instance. Intended for use in integration testing; a major use case is setting up networks of virtual
 * nodes to test network dynamics and routing behavior.
 * 
 * @author Robert Mischke
 */
public class VirtualInstance extends VirtualInstanceSkeleton implements CommonVirtualInstanceControl {

    private static volatile boolean rememberRuntimePeersAfterRestart;

    private CommunicationManagementService managementService;

    private NetworkRoutingService networkRoutingService;

    private MessageRoutingService messageRoutingService;

    private MessageChannelService messageChannelService;

    private ConnectionSetupService connectionSetupService;

    private NodeConfigurationService nodeConfigurationService;

    private VirtualCommunicationBundle virtualCommunicationBundle;

    private CommunicationService communicationService;

    /**
     * Creates a virtual instance with a given log/display name, and its "relay" flag set to "true".
     * 
     * Note that enabling "is relay" by default is done for backward compatibility of integration tests; the actual default configuration
     * setting is "false".
     * 
     * @param displayName the string to use as display/log name
     */
    public VirtualInstance(String displayName) {
        this(displayName, true);
    }

    /**
     * Creates a virtual instance with a given log/display name, and a selectable "relay" flag value.
     * 
     * Note that enabling "is relay" by default is done for backward compatibility of integration tests; the actual default configuration
     * setting is "false".
     * 
     * @param displayName the string to use as display/log name
     * @param isRelay whether the "is relay" flag of this node should be set
     */
    public VirtualInstance(String displayName, boolean isRelay) {
        this(null, displayName, isRelay); // null = assign a random instance id
    }

    /**
     * Creates a virtual instance with the given log/display name, a custom {@link InstanceNodeSessionId}, and a selectable "relay" flag
     * value.
     * 
     * @param predefinedInstanceId an optional predefined instance id's string form; if null, an instance id is created automatically
     * @param displayName the string to use as display/log name
     * @param isRelay whether the "is relay" flag of this node should be set
     */
    public VirtualInstance(String predefinedInstanceId, String displayName, boolean isRelay) {
        super(predefinedInstanceId, displayName, isRelay);

        virtualCommunicationBundle = VirtualCommunicationBundleFactory.createFromNodeConfigurationService(getNodeConfigurationService());
        virtualCommunicationBundle.setAutoStartNetworkOnActivation(false);
        virtualCommunicationBundle.activate();

        nodeConfigurationService = virtualCommunicationBundle.getService(NodeConfigurationService.class);
        messageChannelService = virtualCommunicationBundle.getService(MessageChannelService.class);
        connectionSetupService = virtualCommunicationBundle.getService(ConnectionSetupService.class);
        networkRoutingService = virtualCommunicationBundle.getService(NetworkRoutingService.class);
        messageRoutingService = virtualCommunicationBundle.getService(MessageRoutingService.class);
        managementService = virtualCommunicationBundle.getService(CommunicationManagementService.class);
        communicationService = virtualCommunicationBundle.getService(CommunicationService.class);

        // register custom test message type
        messageChannelService.registerRequestHandler(ProtocolConstants.VALUE_MESSAGE_TYPE_TEST,
            new TestNetworkRequestHandler(nodeConfigurationService.getInstanceNodeSessionId()));
    }

    public static void setRememberRuntimePeersAfterRestarts(boolean rememberRuntimePeers) {
        VirtualInstance.rememberRuntimePeersAfterRestart = rememberRuntimePeers;
    }

    /**
     * Convenience method to send a payload to another node, using the default request timeout.
     * 
     * @param messageContent the request payload
     * @param messageType the message type to send; see {@link ProtocolConstants}
     * @param targetNodeId the id of the destination node
     * @return the response
     * @throws CommunicationException on messaging errors
     * @throws InterruptedException on interruption
     * @throws ExecutionException on internal errors
     * @throws SerializationException on serialization failure
     */
    public NetworkResponse performRoutedRequest(Serializable messageContent, String messageType, InstanceNodeSessionId targetNodeId)
        throws CommunicationException, InterruptedException, ExecutionException, SerializationException {
        byte[] serializedBody = MessageUtils.serializeObject(messageContent);
        return messageRoutingService.performRoutedRequest(serializedBody, messageType, targetNodeId);
    }

    /**
     * Convenience method to send a payload to another node. This method blocks until it has received a response, or the timeout was
     * exceeded.
     * 
     * @param messageContent the request payload
     * @param targetNodeId the id of the destination node
     * @param timeoutMsec the maximum time to wait for the response
     * @return the response
     * @throws CommunicationException on messaging errors
     * @throws InterruptedException on interruption
     * @throws TimeoutException on timeout
     * @throws ExecutionException on internal errors
     * @throws SerializationException on serialization failure
     */
    public NetworkResponse performRoutedRequest(Serializable messageContent, InstanceNodeSessionId targetNodeId, int timeoutMsec)
        throws CommunicationException, InterruptedException, ExecutionException, TimeoutException, SerializationException {
        // FIXME review: after API changes, the given timeout is ignored/overruled by the low-level messaging timeout here
        return performRoutedRequest(messageContent, ProtocolConstants.VALUE_MESSAGE_TYPE_TEST, targetNodeId);
    }

    @Override
    public void addNetworkConnectionListener(MessageChannelLifecycleListener listener) {
        getMessageChannelService().addChannelLifecycleListener(listener);
    }

    @Override
    public void addNetworkTrafficListener(MessageChannelTrafficListener listener) {
        getMessageChannelService().addTrafficListener(listener);
    }

    @Override
    public void simulateCustomProtocolVersion(String version) {
        getService(MessageChannelService.class).overrideProtocolVersion(version);
    }

    @Override
    public synchronized void addInitialNetworkPeer(NetworkContactPoint contactPoint) {
        VirtualInstanceState currentState = getCurrentState();
        if (currentState == VirtualInstanceState.STARTED) {
            // FIXME transitional code; rewrite calls
            log.warn("addInitialNetworkPeer() called for an instance in the STARTED state; change to addRuntimeNetworkPeer()");
            connectAsync(contactPoint);
            return;
        }
        if (currentState != VirtualInstanceState.INITIAL) {
            throw new IllegalStateException("Initial peers can only be added in the INITIAL state");
        }
        super.addInitialNetworkPeer(contactPoint);
    }

    /**
     * Adds and connects to a {@link NetworkContactPoint}.
     * 
     * @param contactPoint the {@link NetworkContactPoint} to add and connect to
     * @return the new {@link ConnectionSetup}
     */
    public synchronized ConnectionSetup connectAsync(NetworkContactPoint contactPoint) {
        if (getCurrentState() != VirtualInstanceState.STARTED) {
            throw new IllegalStateException("Runtime peers can only be added in the STARTED state (is " + getCurrentState() + ")");
        }
        if (rememberRuntimePeersAfterRestart) {
            // add this as an initial peer for next network startup
            super.addInitialNetworkPeer(contactPoint);
        }
        ConnectionSetup connectionSetup = connectionSetupService.createConnectionSetup(contactPoint, null, true);
        connectionSetup.signalStartIntent();
        return connectionSetup;
    }

    /**
     * Initiates a connection to the given {@link NetworkContactPoint} and then waits until it is in the
     * {@link ConnectionSetupState#CONNECTED} state or the timeout is reached, whichever comes first.
     * 
     * @param contactPoint the {@link NetworkContactPoint} to connect to
     * @param timeoutMsec the maximum time to wait return the new {@link ConnectionSetup}
     * @return the new {@link ConnectionSetup}
     * @throws TimeoutException on timeout
     * @throws InterruptedException on thread interruption
     */
    public ConnectionSetup connectAndWait(NetworkContactPoint contactPoint, int timeoutMsec) throws TimeoutException, InterruptedException {
        ConnectionSetup connection = connectAsync(contactPoint);
        connection.awaitState(ConnectionSetupState.CONNECTED, timeoutMsec);
        return connection;
    }

    /**
     * Blocks until the given node is part of this instance's reachable network topology, or until the timeout is reached.
     * 
     * @param targetNodeId the node to wait for
     * @param timeout the maximum time to wait
     * @throws InterruptedException on interruption
     * @throws TimeoutException on timeout
     */
    public void waitUntilContainsInReachableNodes(final InstanceNodeSessionId targetNodeId, int timeout) throws InterruptedException,
        TimeoutException {
        if (containsInReachableNodes(targetNodeId)) {
            return;
        }

        final CountDownLatch latch = new CountDownLatch(1);
        NetworkRoutingServiceImpl networkRoutingServiceImpl = (NetworkRoutingServiceImpl) networkRoutingService;
        NetworkTopologyChangeListenerAdapter topologyChangeListener = new NetworkTopologyChangeListenerAdapter() {

            @Override
            public void onReachableNodesChanged(Set<InstanceNodeSessionId> reachableNodes, Set<InstanceNodeSessionId> addedNodes,
                Set<InstanceNodeSessionId> removedNodes) {
                // log.debug("Reachable nodes for " + getNodeId() + " have changed: " + Arrays.toString(reachableNodes.toArray()));
                if (reachableNodes.contains(targetNodeId)) {
                    latch.countDown();
                }
            }
        };
        try {
            networkRoutingServiceImpl.addNetworkTopologyChangeListener(topologyChangeListener);

            // after registering the listener, check again to prevent race conditions
            if (containsInReachableNodes(targetNodeId)) {
                return;
            }

            long startTime = System.currentTimeMillis();
            if (!latch.await(timeout, TimeUnit.MILLISECONDS)) {
                throw new TimeoutException();
            }
            log.debug(StringUtils.format("Node %s became reachable for node %s after waiting for %,d msec", targetNodeId,
                getInstanceNodeSessionId(),
                System.currentTimeMillis() - startTime));
        } finally {
            networkRoutingServiceImpl.removeNetworkTopologyChangeListener(topologyChangeListener);
        }
    }

    /**
     * Returns whether the given node is part of this instance's reachable network topology.
     * 
     * @param targetNodeId the node to check for
     * @return true if the given node is reachable via the current network
     */
    public boolean containsInReachableNodes(InstanceNodeSessionId targetNodeId) {
        Set<InstanceNodeSessionId> reachable = networkRoutingService.getReachableNetworkGraph().getNodeIds();
        // log.debug("Current reachable nodes for " + getNodeId() + ": " + Arrays.toString(reachable.toArray()));
        return reachable.contains(targetNodeId);
    }

    // TODO examine callers and check if they need to be adapted
    // former return code:
    // return NetworkFormatter.formatTopologyMap(getRoutingService().getProtocolManager().getTopologyMap(), true);
    @Deprecated
    public String getFormattedLegacyNetworkGraph() {
        return getFormattedRawNetworkGraph();
    }

    public String getFormattedRawNetworkGraph() {
        return NetworkFormatter.networkGraphToGraphviz(getRoutingService().getRawNetworkGraph(), true);
    }

    public String getFormattedReachableNetworkGraph() {
        return NetworkFormatter.networkGraphToGraphviz(getRoutingService().getReachableNetworkGraph(), true);
    }

    /**
     * @return a formatted summary of this instance's LSA (routing) knowledge
     */
    public String getFormattedLSAKnowledge() {
        StringBuilder buffer = new StringBuilder();
        Map<InstanceNodeSessionId, Map<String, String>> properties = getService(NodePropertiesService.class).getAllNodeProperties();
        buffer
            .append(StringUtils.format("LSA properties as seen by %s (%d entries):", getInstanceNodeSessionId(), properties.size()));
        for (Entry<InstanceNodeSessionId, Map<String, String>> entry : properties.entrySet()) {
            buffer.append(StringUtils.format("\n  %s: %s", entry.getKey(), entry.getValue().get(NodePropertyConstants.KEY_LSA)));
        }
        return buffer.toString();
    }

    public String getFormattedNetworkStats() {
        return NetworkFormatter.networkStats(getRoutingService().getProtocolManager().getNetworkStats());
    }

    /**
     * Test method: verifies same reporded topology hashes for all known nodes.
     * 
     * TODO verify description
     * 
     * @return true if all hashes are consistent
     */
    // TODO only called by obsolete test case in RoutingProtocolTest
    @Deprecated
    public boolean hasSameTopologyHashesForAllNodes() {
        throw new UnsupportedOperationException("Obsolete method");
        // return getRoutingService().getProtocolManager().getTopologyMap().hasSameTopologyHashesForAllNodes();
    }

    /**
     * Test method: determine the messaging route to the given node.
     * 
     * @param destination the destination node
     * @return the calculated sequence of {@link NetworkGraphLink}s
     */
    public List<? extends NetworkGraphLink> getRouteTo(VirtualInstance destination) {
        return messageRoutingService.getRouteTo(destination.getInstanceNodeSessionId());
    }

    public NetworkGraph getRawNetworkGraph() {
        return networkRoutingService.getRawNetworkGraph();
    }

    public NetworkGraph getReachableNetworkGraph() {
        return networkRoutingService.getReachableNetworkGraph();
    }

    // note: before migration to the new network code, it was kind of undefined whether this method should refer
    // to the *raw* or *reachable* graph; now it does the former - misc_ro
    public int getKnownNodeCount() {
        return getReachableNetworkGraph().getNodeCount();
    }

    /**
     * @param targetInstance the other instance to
     * @return if there is a direct link/channel to the other instance in this instance's known topology
     */
    public boolean knownTopologyContainsLinkTo(VirtualInstance targetInstance) {
        // note: before migration to the new network code, it was kind of undefined whether this method should refer
        // to the *raw* or *reachable* graph; now it does the former - misc_ro
        return getReachableNetworkGraph().containsLinkBetween(this.getInstanceNodeSessionId(),
            targetInstance.getInstanceNodeSessionId());
    }

    /**
     * Test method; probably obsolete.
     * 
     * @param messageId a message id
     * @return whether this message was received (?)
     */
    @Deprecated
    public boolean checkMessageReceivedById(String messageId) {
        return networkRoutingService.getProtocolManager().messageReivedById(messageId);
    }

    /**
     * Test method; probably obsolete.
     * 
     * @param messageContent a message content
     * @return whether this content was received (?)
     */
    @Deprecated
    // check the response received by the initiating node instead
    public boolean checkMessageReceivedByContent(Serializable messageContent) {
        return networkRoutingService.getProtocolManager().messageReivedByContent(messageContent);
    }

    @Override
    public void registerNetworkTransportProvider(NetworkTransportProvider provider) {
        messageChannelService.addNetworkTransportProvider(provider);
    }

    /**
     * @param <T> the service class to fetch
     * @param clazz the service class to fetch
     * @return the registered service of the virtual communication bundle
     */
    public <T> T getService(Class<T> clazz) {
        return virtualCommunicationBundle.getService(clazz);
    }

    /**
     * Allows injection of service instances; useful for remote service call testing.
     * 
     * @param <T> the service interface class
     * @param clazz the service interface class
     * @param implementation the service instance
     */
    public <T> void injectService(Class<T> clazz, T implementation) {
        virtualCommunicationBundle.injectService(clazz, implementation);
    }

    public VirtualCommunicationBundle getVirtualCommunicationBundle() {
        return virtualCommunicationBundle;
    }

    /**
     * Provide unit/integration test access to the management service.
     * 
     * @return The management service.
     */
    public CommunicationManagementService getManagementService() {
        return managementService;
    }

    /**
     * Provide unit/integration test access to the routing service.
     * 
     * @return The communication service.
     */
    public CommunicationService getCommunicationService() {
        return communicationService;
    }

    /**
     * Provide unit/integration test access to the routing service.
     * 
     * @return The routing service.
     */
    public NetworkRoutingService getRoutingService() {
        return networkRoutingService;
    }

    /**
     * Provide unit/integration test access to the connection service.
     * 
     * @return The connection service.
     */
    public MessageChannelService getMessageChannelService() {
        return messageChannelService;
    }

    /**
     * @return The configuration service.
     */
    public NodeConfigurationService getConfigurationService() {
        return super.getNodeConfigurationService();
    }

    public ConnectionSetupService getConnectionSetupService() {
        return virtualCommunicationBundle.getService(ConnectionSetupService.class);
    }

    public NodePropertiesService getNodePropertiesService() {
        return virtualCommunicationBundle.getService(NodePropertiesService.class);
    }

    @Override
    protected void performStartup() throws InterruptedException, CommunicationException {
        managementService.startUpNetwork();
    }

    @Override
    protected void performShutdown() throws InterruptedException {
        managementService.shutDownNetwork();
    }

    @Override
    protected void performSimulatedCrash() throws InterruptedException {
        // simply shut down the network; this should not send any "goodbye" messages etc.
        managementService.simulateUncleanShutdown();
    }

    /**
     * @return the (pseudo-)persistent instance node id of this virtual instance; stable across the lifetime of this {@link VirtualInstance}
     *         object
     */
    public InstanceNodeId getPersistentInstanceNodeId() {
        // TODO WIP
        return getInstanceNodeSessionId().convertToInstanceNodeId();
    }

    /**
     * @return the current instance node session id of this virtual instance; note that this is only defined and stable between calls to
     *         {@link #start()} and {@link #shutDown()}.
     */
    public InstanceNodeSessionId getInstanceNodeSessionId() {
        return nodeInformation.getInstanceNodeSessionId();
    }

    /**
     * @return the equivalent of calling getInstanceNodeSessionId().getInstanceNodeSessionIdString()
     */
    public String getInstanceNodeSessionIdString() {
        return nodeInformation.getInstanceNodeSessionId().getInstanceNodeSessionIdString();
    }

    @Override
    public String toString() {
        // TODO Auto-generated method stub
        return getInstanceNodeSessionId().toString();
    }

    /**
     * @return the first server contact point; if none exists, an exception is thrown
     */
    public NetworkContactPoint getDefaultContactPoint() {
        List<NetworkContactPoint> serverContactPoints = getConfigurationService().getServerContactPoints();
        if (serverContactPoints.isEmpty()) {
            throw new IllegalStateException("No server contact points configured");
        }
        return serverContactPoints.get(0);
    }

}
