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

package de.rcenvironment.core.communication.routing.internal;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import de.rcenvironment.core.communication.api.NodeIdentifierService;
import de.rcenvironment.core.communication.channel.MessageChannelService;
import de.rcenvironment.core.communication.common.IdentifierException;
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.NodeIdentifierContextHolder;
import de.rcenvironment.core.communication.common.NodeIdentifierUtils;
import de.rcenvironment.core.communication.configuration.NodeConfigurationService;
import de.rcenvironment.core.communication.messaging.direct.api.DirectMessagingSender;
import de.rcenvironment.core.communication.model.InitialNodeInformation;
import de.rcenvironment.core.communication.model.NetworkRequest;
import de.rcenvironment.core.communication.model.NetworkResponse;
import de.rcenvironment.core.communication.model.NetworkResponseHandler;
import de.rcenvironment.core.communication.model.internal.NetworkGraphImpl;
import de.rcenvironment.core.communication.model.internal.NetworkGraphLinkImpl;
import de.rcenvironment.core.communication.protocol.MessageMetaData;
import de.rcenvironment.core.communication.protocol.NetworkRequestFactory;
import de.rcenvironment.core.communication.protocol.NetworkResponseFactory;
import de.rcenvironment.core.communication.routing.InstanceRestartAndPresenceService;
import de.rcenvironment.core.communication.routing.InstanceSessionNetworkStatus;
import de.rcenvironment.core.communication.routing.MessageRoutingService;
import de.rcenvironment.core.communication.routing.NetworkRoutingService;
import de.rcenvironment.core.communication.routing.InstanceSessionNetworkStatus.State;
import de.rcenvironment.core.communication.routing.internal.v2.Link;
import de.rcenvironment.core.communication.routing.internal.v2.LinkState;
import de.rcenvironment.core.communication.routing.internal.v2.LinkStateKnowledgeChangeListener;
import de.rcenvironment.core.communication.routing.internal.v2.NoRouteToNodeException;
import de.rcenvironment.core.communication.spi.NetworkTopologyChangeListener;
import de.rcenvironment.core.communication.spi.NetworkTopologyChangeListenerAdapter;
import de.rcenvironment.core.configuration.bootstrap.RuntimeDetection;
import de.rcenvironment.core.toolkitbridge.transitional.StatsCounter;
import de.rcenvironment.core.utils.common.StringUtils;
import de.rcenvironment.core.utils.common.service.AdditionalServiceDeclaration;
import de.rcenvironment.core.utils.common.service.AdditionalServicesProvider;
import de.rcenvironment.core.utils.incubator.DebugSettings;

/**
 * A implementation of the {@link NetworkRoutingService} interface.
 * 
 * @author Phillip Kroll
 * @author Robert Mischke
 */
public class NetworkRoutingServiceImpl
    implements NetworkRoutingService, MessageRoutingService, InstanceRestartAndPresenceService, AdditionalServicesProvider {

    /**
     * Keeps track of of distributed link state changes to adapt the local graph knowledge.
     * 
     * @author Robert Mischke
     */
    private final class LinkStateKnowledgeChangeTracker implements LinkStateKnowledgeChangeListener {

        // private LinkState localLinkState = new LinkState(new ArrayList<Link>());

        @Override
        public void onLinkStateKnowledgeChanged(Map<InstanceNodeSessionId, LinkState> knowledge) {
            if (verboseLogging) {
                StringBuilder buffer = new StringBuilder();
                buffer.append(StringUtils.format("New link state knowledge of %s (%d entries):", localInstanceSessionId, knowledge.size()));
                for (Entry<InstanceNodeSessionId, LinkState> entry : knowledge.entrySet()) {
                    buffer.append(StringUtils.format("\n  Link state for %s: %s", entry.getKey(), entry.getValue()));
                }
                log.debug(buffer.toString());
            }

            if (knowledge.size() == 0) {
                // before any link state is known, remain at the initial placeholder model
                return;
            }
            // consistency check
            if (localInstanceSessionId == null) {
                throw new IllegalStateException();
            }

            NetworkGraphImpl rawGraph = new NetworkGraphImpl(localInstanceSessionId);

            Set<InstanceNodeSessionId> nodeIdsWithLinkState = knowledge.keySet();
            for (InstanceNodeSessionId nodeId : nodeIdsWithLinkState) {
                if (nodeId == null) {
                    throw new IllegalArgumentException("Map contained 'null' node id");
                }
                addNode(rawGraph, nodeId);
            }
            // consistency check
            int expectedGraphSize = knowledge.size();
            if (!knowledge.containsKey(localInstanceSessionId)) {
                expectedGraphSize++;
            }
            if (rawGraph.getNodeCount() != expectedGraphSize) {
                throw new IllegalStateException(StringUtils.format("Graph with %d nodes constructed, but expectes size was %d",
                    rawGraph.getNodeCount(), localInstanceSessionId));
            }

            int totalLinks = 0;
            for (Map.Entry<InstanceNodeSessionId, LinkState> entry : knowledge.entrySet()) {
                InstanceNodeSessionId sourceNodeId = entry.getKey();
                LinkState linkState = entry.getValue();
                List<Link> links = linkState.getLinks();
                addLinks(rawGraph, sourceNodeId, links);
                totalLinks += linkState.getLinks().size();
            }
            // consistency check
            if (rawGraph.getLinkCount() != totalLinks) {
                throw new IllegalStateException();
            }

            updateFromRawNetworkGraph(rawGraph);
        }

        private void addNode(NetworkGraphImpl rawGraph, InstanceNodeSessionId nodeId) {
            rawGraph.addNode(nodeId);
        }

        private void addLinks(NetworkGraphImpl rawGraph, InstanceNodeSessionId sourceNodeId, List<Link> links) {
            for (Link link : links) {
                InstanceNodeSessionId targetNodeId;
                try {
                    targetNodeId = nodeIdentifierService.parseInstanceNodeSessionIdString(link.getNodeIdString());
                } catch (IdentifierException e) {
                    // note: currently not handling malformed ids here; will throw RTE on failure
                    throw NodeIdentifierUtils.wrapIdentifierException(e);
                }
                rawGraph.addLink(new NetworkGraphLinkImpl(link.getLinkId(), sourceNodeId, targetNodeId));
            }
        }

        @Override
        public void onLinkStatesUpdated(Map<InstanceNodeSessionId, LinkState> delta) {
            if (verboseLogging) {
                log.debug("Updated link states for " + delta.size() + " nodes: " + delta.keySet());
            }
        }

        @Override
        public void onLocalLinkStateUpdated(LinkState linkState) {
            if (verboseLogging) {
                log.debug("Local link state updated (for " + localInstanceSessionId + "): " + linkState);
            }
            // localLinkState = linkState;
        }
    }

    /**
     * Initial listener for low-level topology changes. This is the only listener that receives change events from the routing layer, and
     * delegates these events to external listeners. Each callback to an external listener is performed in a separate thread to prevent
     * blocking listeners from affecting the calling code.
     * 
     * @author Robert Mischke
     */
    private class LowLevelNetworkTopologyChangeHandler extends NetworkTopologyChangeListenerAdapter {

        @Override
        public void onNetworkTopologyChanged() {
            NetworkGraphImpl rawNetworkGraph;
            synchronized (topologyMap) {
                log.debug(StringUtils.format("Low-level topology change detected; the topology map of %s"
                    + " now contains %d node(s) and %d connection(s)", localInstanceSessionId,
                    topologyMap.getNodeCount(), topologyMap.getLinkCount()));

                rawNetworkGraph = (NetworkGraphImpl) topologyMap.toRawNetworkGraph();
            }

            // forward to outer class
            // updateFromRawNetworkGraph(rawNetworkGraph);
        }
    }

    private InitialNodeInformation ownNodeInformation;

    private MessageChannelService messageChannelService;

    private DirectMessagingSender directMessagingSender;

    private NodeConfigurationService nodeConfigurationService;

    private LinkStateRoutingProtocolManager protocolManager;

    private volatile NetworkGraphImpl cachedRawNetworkGraph;

    private volatile NetworkGraphImpl cachedReachableNetworkGraph;

    private InstanceNodeSessionId localInstanceSessionId;

    private TopologyMap topologyMap;

    private final NetworkTopologyChangeTracker topologyChangeTracker = new NetworkTopologyChangeTracker();

    private final boolean verboseLogging = DebugSettings.getVerboseLoggingEnabled(getClass());

    // NOTE: used in several locations
    private final boolean forceLocalRPCSerialization = System
        .getProperty(NodeConfigurationService.SYSTEM_PROPERTY_FORCE_LOCAL_RPC_SERIALIZATION) != null;

    private final Log log = LogFactory.getLog(getClass());

    private int routedRequestTimeoutMsec;

    private int forwardingTimeoutMsec;

    private NodeIdentifierService nodeIdentifierService;

    /**
     * OSGi activate method.
     */
    public void activate() {
        if (RuntimeDetection.isImplicitServiceActivationDenied()) {
            // do not activate this service if is was spawned as part of a default test environment
            return;
        }
        
        ownNodeInformation = nodeConfigurationService.getInitialNodeInformation();
        routedRequestTimeoutMsec = nodeConfigurationService.getRequestTimeoutMsec();
        forwardingTimeoutMsec = nodeConfigurationService.getForwardingTimeoutMsec();
        localInstanceSessionId = ownNodeInformation.getInstanceNodeSessionId();

        // create initial placeholders
        NetworkGraphImpl initialRawNetworkGraph = new NetworkGraphImpl(localInstanceSessionId);
        updateFromRawNetworkGraph(initialRawNetworkGraph);

        // initialize tracker with initial graph
        topologyChangeTracker.updateReachableNetwork(cachedReachableNetworkGraph);

        // topologyMap = new TopologyMap(ownNodeInformation);
        // protocolManager = new LinkStateRoutingProtocolManager(topologyMap, connectionService, new
        // LowLevelNetworkTopologyChangeHandler());

        // TODO set here to break up cyclic dependency; refactor? - misc_ro
        messageChannelService.setForwardingService(this);
    }

    @Override
    public Collection<AdditionalServiceDeclaration> defineAdditionalServices() {
        List<AdditionalServiceDeclaration> result = new ArrayList<AdditionalServiceDeclaration>();
        result.add(new AdditionalServiceDeclaration(LinkStateKnowledgeChangeListener.class, new LinkStateKnowledgeChangeTracker()));
        return result;
    }

    @Override
    public NetworkResponse performRoutedRequest(byte[] payload, String messageType, InstanceNodeSessionId receiver) {
        // TODO find a more generic solution to this
        final NodeIdentifierService previousService =
            NodeIdentifierContextHolder.setDeserializationServiceForCurrentThread(nodeIdentifierService);
        try {
            return performRoutedRequest(payload, messageType, receiver, routedRequestTimeoutMsec);
        } finally {
            NodeIdentifierContextHolder.setDeserializationServiceForCurrentThread(previousService);
        }
    }

    @Override
    public NetworkResponse performRoutedRequest(byte[] payload, String messageType, InstanceNodeSessionId receiver, int timeoutMsec) {
        final NetworkRequest request = NetworkRequestFactory.createNetworkRequest(payload, messageType, localInstanceSessionId, receiver);
        if (forceLocalRPCSerialization && receiver.equals(localInstanceSessionId)) {
            return messageChannelService.handleLocalForcedSerializationRPC(request, localInstanceSessionId);
        }
        return sendToNextHopAndAwaitResponse(request, timeoutMsec);
    }

    @Override
    public NetworkResponse forwardAndAwait(NetworkRequest forwardingRequest) {
        // TODO refactor/improve?
        return forwardToNextHop(forwardingRequest);
    }

    @Override
    public List<? extends NetworkGraphLink> getRouteTo(InstanceNodeSessionId destination) {
        return cachedReachableNetworkGraph.getRoutingInformation().getRouteTo(destination);
    }

    @Override
    public synchronized NetworkGraph getRawNetworkGraph() {
        return cachedRawNetworkGraph;
    }

    @Override
    public synchronized NetworkGraph getReachableNetworkGraph() {
        return cachedReachableNetworkGraph;
    }

    /**
     * TODO Restrict method visibility.
     * 
     * @return Returns the protocol.
     */
    @Override
    public LinkStateRoutingProtocolManager getProtocolManager() {
        return protocolManager;
    }

    /**
     * OSGi-DS bind method; public for integration test access.
     * 
     * @param service The network connection service.
     */
    public void bindMessageChannelService(MessageChannelService service) {
        // do not allow rebinding for now
        if (this.messageChannelService != null) {
            throw new IllegalStateException();
        }
        this.messageChannelService = service;
        // note: currently extending each other
        this.directMessagingSender = service;
    }

    /**
     * OSGi-DS bind method; public for integration test access.
     * 
     * @param service The configuration service.
     */
    public void bindNodeConfigurationService(NodeConfigurationService service) {
        // do not allow rebinding for now
        if (this.nodeConfigurationService != null) {
            throw new IllegalStateException();
        }
        this.nodeConfigurationService = service;
        this.nodeIdentifierService = nodeConfigurationService.getNodeIdentifierService();
    }

    /**
     * Adds a new {@link NetworkTopologyChangeListener}. This method is not part of the service interface; it is only meant to be used via
     * OSGi-DS (whiteboard pattern) and integration tests.
     * 
     * @param listener the listener
     */
    public void addNetworkTopologyChangeListener(NetworkTopologyChangeListener listener) {
        topologyChangeTracker.addListener(listener);
    }

    /**
     * Removes a {@link NetworkTopologyChangeListener}. This method is not part of the service interface; it is only meant to be used via
     * OSGi-DS (whiteboard pattern) and integration tests.
     * 
     * @param listener the listener
     */
    public void removeNetworkTopologyChangeListener(NetworkTopologyChangeListener listener) {
        topologyChangeTracker.removeListener(listener);
    }

    @Override
    public String getFormattedNetworkInformation(String type) {
        if ("info".equals(type)) {
            return NetworkFormatter.networkGraphToConsoleInfo(cachedReachableNetworkGraph);
        }
        if ("graphviz".equals(type)) {
            return NetworkFormatter.networkGraphToGraphviz(cachedReachableNetworkGraph, true);
        }
        if ("graphviz-all".equals(type)) {
            return NetworkFormatter.networkGraphToGraphviz(cachedRawNetworkGraph, true);
        }
        throw new IllegalArgumentException("Invalid type: " + type);
    }

    @Override
    public InstanceSessionNetworkStatus queryInstanceSessionNetworkStatus(InstanceNodeSessionId lookupId) {
        return topologyChangeTracker.queryInstanceSessionNetworkStatus(lookupId);
    }

    protected synchronized void updateFromRawNetworkGraph(NetworkGraphImpl rawNetworkGraph) {

        cachedRawNetworkGraph = rawNetworkGraph;
        cachedReachableNetworkGraph = rawNetworkGraph.reduceToReachableGraph();

        if (verboseLogging) {
            log.debug(StringUtils.format(
                "Updating %s with a raw graph of %d nodes and %d edges resulted in a reachable graph of %d nodes and %d edges",
                localInstanceSessionId,
                rawNetworkGraph.getNodeCount(), rawNetworkGraph.getLinkCount(),
                cachedReachableNetworkGraph.getNodeCount(), cachedReachableNetworkGraph.getLinkCount()));
        }

        // FIXME debug output; remove when done
        // log.debug("Raw network graph update:\n" + NetworkFormatter.networkGraphToGraphviz(cachedRawNetworkGraph, true));
        // log.debug("Reachable network graph update:\n" + NetworkFormatter.networkGraphToGraphviz(cachedReachableNetworkGraph, true));

        StatsCounter.count("Network topology/routing", "Network graph changes");
        if (topologyChangeTracker.updateReachableNetwork(cachedReachableNetworkGraph)) {
            StatsCounter.count("Network topology/routing", "Set of reachable nodes changes");
        } else {
            if (verboseLogging) {
                log.debug("Ignoring low-level topology change event, as it had no effect on the set of reachable nodes");
            }
        }
    }

    private NetworkResponse forwardToNextHop(final NetworkRequest forwardingRequest) {
        // extract common metadata for logging
        MessageMetaData metadata = forwardingRequest.accessMetaData();
        String requestId = forwardingRequest.getRequestId();
        String localNodeIdString = localInstanceSessionId.getInstanceNodeSessionIdString();
        String sender = metadata.getSenderIdString();
        String receiver = metadata.getFinalRecipientIdString();

        // TODO while improved in 7.0, this still blocks one thread for each forwarded request
        final NetworkResponse response = sendToNextHopAndAwaitResponse(forwardingRequest, forwardingTimeoutMsec);
        // should be redundant; can be removed in 8.0.0
        if (response == null) {
            throw new IllegalStateException(
                StringUtils.format("NULL response after forwarding message from %s to %s at %s (ReqId=%s)", sender,
                    receiver, localNodeIdString, requestId));
        }
        return response;
    }

    private NetworkResponse sendToNextHopAndAwaitResponse(final NetworkRequest request, int timeoutMsec) {
        WaitForResponseBlocker responseBlocker = new WaitForResponseBlocker(request, localInstanceSessionId);
        sendToNextHopAsync(request, responseBlocker);
        return responseBlocker.await(timeoutMsec);

        // TODO review: attach error source or trace information here if the response does not represent success

        // try {
        // response = responseFuture.get(configurationService.getForwardingTimeoutMsec(), TimeUnit.MILLISECONDS);
        // } catch (TimeoutException e) {
        // log.warn(StringUtils.format("Timeout while forwarding message from %s to %s at %s (ReqId=%s)", sender, receiver,
        // ownNodeIdString, requestId));
        // response = NetworkResponseFactory.generateResponseForExceptionDuringDelivery(forwardingRequest, ownNodeIdString, e);
        // } catch (InterruptedException e) {
        // log.warn(StringUtils.format("Interrupted while forwarding message from %s to %s at %s (ReqId=%s)", sender, receiver,
        // ownNodeIdString, requestId), e);
        // response = NetworkResponseFactory.generateResponseForExceptionDuringDelivery(forwardingRequest, ownNodeIdString, e);
        // } catch (ExecutionException e) {
        // log.warn(
        // StringUtils.format("Error while forwarding message from %s to %s at %s (ReqId=%s)", sender,
        // receiver, ownNodeIdString, requestId), e);
        // response = NetworkResponseFactory.generateResponseForExceptionDuringDelivery(forwardingRequest, ownNodeIdString, e);
        // }

    }

    private void sendToNextHopAsync(final NetworkRequest request, NetworkResponseHandler responseHandler) {
        InstanceNodeSessionId receiver = request.accessMetaData().getFinalRecipient();
        NetworkGraphLink nextLink;
        try {
            // TODO move routing into Callable for faster return of caller thread? (still relevant @4.0?) - misc_ro
            nextLink = cachedReachableNetworkGraph.getRoutingInformation().getNextLinkTowards(receiver);
        } catch (NoRouteToNodeException e) {
            final InstanceNodeSessionId sender = request.accessMetaData().getSender();
            log.debug(StringUtils.format("Found no route for a request from %s to %s (type=%s, trace=%s)",
                sender, receiver, request.getMessageType(), request.accessMetaData().getTrace()));

            final NetworkResponse response;

            InstanceSessionNetworkStatus instanceSessionState = topologyChangeTracker.queryInstanceSessionNetworkStatus(receiver);
            final State state = instanceSessionState.getState();
            switch (state) {
            case NOT_PRESENT:
                // not present
                if (localInstanceSessionId.equals(sender)) {
                    response = NetworkResponseFactory.generateResponseForNoRouteAtSender(request, localInstanceSessionId);
                } else {
                    response = NetworkResponseFactory.generateResponseForNoRouteWhileForwarding(request, localInstanceSessionId);
                }
                break;
            case PRESENT:
                // present -> assume general network error
                log.warn("Unusual situation: a network request failed because no route to the "
                    + "target node was found, but it is considered visible in the current network");
                if (localInstanceSessionId.equals(sender)) {
                    response = NetworkResponseFactory.generateResponseForNoRouteAtSender(request, localInstanceSessionId);
                } else {
                    response = NetworkResponseFactory.generateResponseForNoRouteWhileForwarding(request, localInstanceSessionId);
                }
                break;
            case PRESENT_WITH_DIFFERENT_SESSION:
                // the target node was restarted and has reconnected
                log.warn("Attempting for forward a network message to " + receiver
                    + ", but the remote node was restarted; its new session id is " + instanceSessionState.getOtherId());
                response =
                    NetworkResponseFactory.generateResponseForTargetNodeWasRestarted(request, localInstanceSessionId);
                break;
            case ID_COLLISION:
                log.error(
                    "There is more than one node with the same instance id within the network; this is not allowed. "
                        + "A typical cause for this is when entire profiles are copied (including their internal storage), "
                        + "and the copies are being used at the same time. Node 1: " + instanceSessionState.getQueriedId() + ", Node 2: "
                        + instanceSessionState.getOtherId());
                response =
                    NetworkResponseFactory.generateResponseForInstanceIdCollision(request, localInstanceSessionId);
                break;
            default:
                throw new IllegalArgumentException();
            }

            // send to result handler
            responseHandler.onResponseAvailable(response);
            return;
        }

        // if (verboseLogging) {
        // log.debug(StringUtils.format("Sending routed message for %s towards %s via link %s",
        // receiver, nextLink.getTargetNodeId(), nextLink.getLinkId()));
        // }

        sendDirectMessageAsync(request, nextLink, responseHandler);

        // TODO restore routing retry? (on higher call level?)

        // // if there is a route, use it
        // int routeRetries = 0;
        // while (route.validate()) {
        //
        // // forward message content to next network contact point on the route
        // WaitForResponseCallable responseCallable = new WaitForResponseCallable();
        // if (protocolManager.sendTowardsNeighbor(messageContent, metaData, route.getFirstLink(),
        // responseCallable)) {
        // return executorService.submit(responseCallable);
        // } else {
        // routeRetries++;
        // // TODO make limit a constant
        // if (routeRetries >= 3) {
        // break;
        // }
        // // try to get a new route.
        // // TODO add retry limit? -- misc_ro
        // route = protocolManager.getRouteTo(receiver);
        // }
        // }
        // throw new CommunicationException(StringUtils.format("'%s' could not find a route to '%s'.",
        // ownNodeId, receiver));
    }

    /**
     * Central method to send a {@link NetworkRequest} into a {@link NetworkGraphLink}. No routing is involved here anymore.
     * 
     * @param request the {@link NetworkRequest} to send
     * @param link the {@link NetworkGraphLink} identifying the message channel to use
     * @param outerResponseHander the {@link NetworkResponseHandler} to report the response to
     */
    private void sendDirectMessageAsync(NetworkRequest request, final NetworkGraphLink link,
        final NetworkResponseHandler outerResponseHander) {

        if (outerResponseHander == null) {
            throw new IllegalArgumentException("Outer response handler must not be null");
        }

        NetworkResponseHandler responseHandler = new NetworkResponseHandler() {

            @Override
            public void onResponseAvailable(NetworkResponse response) {
                // TODO add failure backtrace route here?
                // if (!response.isSuccess()) {
                // }
                outerResponseHander.onResponseAvailable(response);
            }

        };

        directMessagingSender.sendDirectMessageAsync(request, link.getLinkId(), responseHandler);
    }
}
