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

package de.rcenvironment.core.notification.internal;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.SortedSet;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.WeakHashMap;
import java.util.concurrent.CountDownLatch;

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

import de.rcenvironment.core.communication.api.PlatformService;
import de.rcenvironment.core.notification.Notification;
import de.rcenvironment.core.notification.NotificationHeader;
import de.rcenvironment.core.notification.NotificationService;
import de.rcenvironment.core.notification.NotificationSubscriber;
import de.rcenvironment.core.toolkitbridge.transitional.ConcurrencyUtils;
import de.rcenvironment.core.toolkitbridge.transitional.StatsCounter;
import de.rcenvironment.core.utils.common.rpc.RemoteOperationException;
import de.rcenvironment.core.utils.common.security.AllowRemoteAccess;
import de.rcenvironment.core.utils.incubator.DebugSettings;
import de.rcenvironment.toolkit.modules.concurrency.api.AsyncCallbackExceptionPolicy;
import de.rcenvironment.toolkit.modules.concurrency.api.AsyncOrderedExecutionQueue;
import de.rcenvironment.toolkit.modules.concurrency.api.BatchAggregator;
import de.rcenvironment.toolkit.modules.concurrency.api.BatchProcessor;

/**
 * Implementation of the {@link NotificationService}.
 * 
 * @author Andre Nurzenski
 * @author Doreen Seider
 * @author Robert Mischke
 */
public class NotificationServiceImpl implements NotificationService {

    private static final boolean TOPIC_STATISTICS_ENABLED = false;

    private static final boolean FEATURE_FLAG_USE_ASYNCHRONOUS_SENDING = false;

    private static final String VERBOSE_LOG_OUTPUT_SEPARATOR = " / ";

    /**
     * Helper class to hold local information about subscribers. This includes a set of the subscribed topics, and a {@link BatchAggregator}
     * to group messages to this subscriber.
     * 
     * @author Robert Mischke
     */
    private static final class LocalSubscriberMetaData {

        private final Set<NotificationTopic> subscribedTopics;

        private final BatchAggregator<Notification> batchAggregator;

        /**
         * @param batchAggregator the aggregator instance to use for this subscriber
         */
        LocalSubscriberMetaData(BatchAggregator<Notification> batchAggregator) {
            this.batchAggregator = batchAggregator;
            this.subscribedTopics = new HashSet<NotificationTopic>();
        }

        /**
         * Adds a {@link NotificationTopic} that this subscriber has registered for. Used via {@link #getSubscribedTopics()} to unsubscribe
         * from all topics if necessary.
         * 
         * @param topic the already-subscribed topic
         */
        public void addSubscribedTopic(NotificationTopic topic) {
            synchronized (subscribedTopics) {
                subscribedTopics.add(topic);
            }
        }

        /**
         * Adds a {@link NotificationTopic} that this subscriber is no longer registered for.
         * 
         * @param topic the topic to disconnect from this subscriber
         */
        public boolean removeSubscribedTopic(NotificationTopic topic) {
            synchronized (subscribedTopics) {
                return subscribedTopics.remove(topic);
            }
        }

        public Collection<NotificationTopic> getSubscribedTopics() {
            synchronized (subscribedTopics) {
                // copy to immutable collection to prevent concurrent modifications
                return new ArrayList<NotificationTopic>(subscribedTopics);
            }
        }

        public BatchAggregator<Notification> getBatchAggregator() {
            return batchAggregator;
        }

    }

    /**
     * A {@link BatchProcessor} implementation that sends out batches of {@link Notification}s to a single {@link NotificationSubscriber}.
     * 
     * @author Robert Mischke
     */
    private final class NotificationBatchSender implements BatchProcessor<Notification> {

        private NotificationSubscriber subscriber;

        /**
         * @param subscriber the subscriber to send received batches to
         */
        NotificationBatchSender(NotificationSubscriber subscriber) {
            this.subscriber = subscriber;
        }

        @Override
        public void processBatch(List<Notification> batch) {
            sendNotificationsToSubscriber(subscriber, batch);
        }
    }

    // the maximum number of notifications to aggregate to a single batch
    // NOTE: arbitrary value; adjust when useful/necessary
    private static final int MAX_NOTIFICATION_BATCH_SIZE = 50;

    // the maximum time a notification may be delayed by batch aggregation
    // NOTE: arbitrary value; adjust when useful/necessary
    private static final long MAX_NOTIFICATION_LATENCY = 100;

    private static final Log LOGGER = LogFactory.getLog(NotificationServiceImpl.class);

    private final boolean verboseLogging = DebugSettings.getVerboseLoggingEnabled("Notifications");

    /** Local topics. */
    private Map<String, NotificationTopic> topics = Collections.synchronizedMap(new HashMap<String, NotificationTopic>());

    /** Current number of all notifications. */
    private Map<String, Long> currentNumbers = Collections.synchronizedMap(new HashMap<String, Long>());

    /** Buffer sizes of all notifications. */
    private Map<String, Integer> bufferSizes = Collections.synchronizedMap(new HashMap<String, Integer>());

    /** Stored notifications. */
    private Map<String, SortedMap<NotificationHeader, Notification>> allNotifications =
        Collections.synchronizedMap(new HashMap<String, SortedMap<NotificationHeader, Notification>>());

    private WeakHashMap<NotificationSubscriber, LocalSubscriberMetaData> subscriberMap =
        new WeakHashMap<NotificationSubscriber, NotificationServiceImpl.LocalSubscriberMetaData>();

    private PlatformService platformService;

    private final AsyncOrderedExecutionQueue deferredPublishingQueue =
        ConcurrencyUtils.getFactory().createAsyncOrderedExecutionQueue(AsyncCallbackExceptionPolicy.LOG_AND_PROCEED);

    protected void bindPlatformService(PlatformService newPlatformService) {
        platformService = newPlatformService;
    }

    @Override
    public void setBufferSize(String notificationId, int bufferSize) {
        if (bufferSize != 0) {
            bufferSizes.put(notificationId, new Integer(bufferSize));
            // synchronize explicitly to avoid potential "lost update" problem
            synchronized (allNotifications) {
                if (!allNotifications.containsKey(notificationId)) {
                    allNotifications.put(notificationId, new TreeMap<NotificationHeader, Notification>());
                }
            }
        }
    }

    @Override
    public void removePublisher(final String notificationId) {

        if (FEATURE_FLAG_USE_ASYNCHRONOUS_SENDING) {

            deferredPublishingQueue.enqueue(new Runnable() {

                @Override
                public void run() {
                    deleteTopicInternal(notificationId);
                }
            });

        } else {

            // old blocking behavior
            deleteTopicInternal(notificationId);

        }

    }

    @Override
    public <T extends Serializable> void send(final String notificationId, final T notificationBody) {

        if (verboseLogging) {
            LOGGER.debug("send(): " + notificationId + VERBOSE_LOG_OUTPUT_SEPARATOR + notificationBody);
        }

        if (FEATURE_FLAG_USE_ASYNCHRONOUS_SENDING) {

            deferredPublishingQueue.enqueue(new Runnable() {

                @Override
                public void run() {
                    sendInternal(notificationId, notificationBody);
                }
            });

        } else {

            // old blocking behavior
            sendInternal(notificationId, notificationBody);

        }
    }

    private void deleteTopicInternal(String notificationId) {
        synchronized (topics) {
            NotificationTopic topic = getNotificationTopic(notificationId);
            if (topic != null) {
                topics.remove(topic.getName());
                currentNumbers.remove(notificationId);
                bufferSizes.remove(notificationId);
                allNotifications.remove(notificationId);
            }
        }
    }

    private synchronized <T extends Serializable> void sendInternal(String notificationId, T notificationBody) {

        if (TOPIC_STATISTICS_ENABLED) {
            if (StatsCounter.isEnabled()) {
                StatsCounter.count("Notifications sent by id", notificationId);
                StatsCounter.countClass("Notifications sent by body type", notificationBody);
            }
        }

        if (getNotificationTopic(notificationId) == null) {
            registerNotificationTopic(notificationId);
        }

        Long currentEdition = currentNumbers.get(notificationId);
        Notification notification = new Notification(notificationId, currentEdition.longValue() + 1,
            platformService.getLocalInstanceNodeSessionId(), notificationBody);

        SortedMap<NotificationHeader, Notification> notifications = allNotifications.get(notificationId);
        if (notifications != null) {
            Integer bufferSize = bufferSizes.get(notificationId);

            if (bufferSize > 0 && notifications.size() >= bufferSize) {
                if (notifications.remove(notifications.firstKey()) != null) {
                    notifications.put(notification.getHeader(), notification);
                }
            } else {
                notifications.put(notification.getHeader(), notification);
            }
        }

        for (NotificationTopic matchingTopic : getMatchingNotificationTopics(notificationId)) {
            for (NotificationSubscriber subscriber : matchingTopic.getSubscribers()) {
                if (TOPIC_STATISTICS_ENABLED) {
                    if (StatsCounter.isEnabled()) {
                        StatsCounter.count("Notifications enqueued by type", notificationId);
                    }
                }
                sendNotificationToSubscriber(notification, subscriber);
            }
        }

        // TODO review: is this guaranteed to be consistent with asynchronous sending? -- misc_ro
        currentNumbers.put(notificationId, notification.getHeader().getNumber());
    }

    private void sendNotificationToSubscriber(Notification notification, NotificationSubscriber subscriber) {
        getLocalSubscriberMetaData(subscriber).getBatchAggregator().enqueue(notification);
    }

    @Override
    @AllowRemoteAccess
    public Map<String, Long> subscribe(String notificationId, NotificationSubscriber subscriber) {

        if (verboseLogging) {
            LOGGER.debug("incoming subscribe(): " + notificationId + VERBOSE_LOG_OUTPUT_SEPARATOR + subscriber);
        }

        Map<String, Long> lastNumbers = new HashMap<String, Long>();
        NotificationTopic topic;

        synchronized (topics) {
            topic = getNotificationTopic(notificationId);
            if (topic == null) {
                topic = registerNotificationTopic(notificationId);
                if (TOPIC_STATISTICS_ENABLED) {
                    if (StatsCounter.isEnabled()) {
                        StatsCounter.count("Register Topic", notificationId);
                    }
                }
            }
        }
        topic.add(subscriber);
        getLocalSubscriberMetaData(subscriber).addSubscribedTopic(topic);

        synchronized (currentNumbers) {
            for (String tmpId : currentNumbers.keySet()) {
                if (tmpId.matches(notificationId)) {
                    lastNumbers.put(tmpId, currentNumbers.get(tmpId));
                }
            }
        }

        return lastNumbers;
    }

    @Override
    @AllowRemoteAccess
    public void unsubscribe(String notificationId, NotificationSubscriber subscriber) {

        if (verboseLogging) {
            LOGGER.debug("incoming unsubscribe(): " + notificationId + VERBOSE_LOG_OUTPUT_SEPARATOR + subscriber);
        }

        synchronized (topics) {
            NotificationTopic topic = getNotificationTopic(notificationId);
            if (topic != null) {
                topic.remove(subscriber);
                getLocalSubscriberMetaData(subscriber).removeSubscribedTopic(topic);
            }
        }
    }

    @Override
    public Notification getNotification(NotificationHeader header) {

        Notification notification = null;
        Map<NotificationHeader, Notification> notifications = allNotifications.get(header.getNotificationIdentifier());
        if (notifications != null) {
            notification = notifications.get(header);
        }
        return notification;
    }

    @Override
    public Map<String, SortedSet<NotificationHeader>> getNotificationHeaders(String notificationId) {

        Map<String, SortedSet<NotificationHeader>> allHeaders = new HashMap<String, SortedSet<NotificationHeader>>();

        // note: access to iterators of synchronized maps must be synchronized explicitly
        synchronized (allNotifications) {
            // TODO iterating over map entries would probably be more efficient
            for (String tmpId : allNotifications.keySet()) {
                if (tmpId.matches(notificationId)) {
                    Map<NotificationHeader, Notification> notifications = allNotifications.get(tmpId);
                    SortedSet<NotificationHeader> headers = new TreeSet<NotificationHeader>(notifications.keySet());
                    allHeaders.put(tmpId, headers);
                }
            }
        }

        return allHeaders;
    }

    @Override
    @AllowRemoteAccess
    public Map<String, List<Notification>> getNotifications(String notificationId) {

        Map<String, List<Notification>> allNotificationsToGet = new HashMap<String, List<Notification>>();

        // note: access to iterators of synchronized maps must be synchronized explicitly
        synchronized (allNotifications) {
            // TODO iterating over map entries would probably be more efficient
            for (String tmpId : allNotifications.keySet()) {
                if (tmpId.matches(notificationId)) {
                    Map<NotificationHeader, Notification> notifications = allNotifications.get(tmpId);
                    List<Notification> notificationsToGet = new ArrayList<Notification>(notifications.values());
                    allNotificationsToGet.put(tmpId, notificationsToGet);
                }
            }
        }
        return allNotificationsToGet;
    }

    /**
     * Sends a single {@link Notification} to a {@link NotificationSubscriber}.
     * 
     * @param subscriber the subscriber to send the notification to
     * @param matchingTopic the matching topic that caused the subscriber to receive this notification
     * @param notifications the notifications, ie the actual content
     */
    private void sendNotificationsToSubscriber(NotificationSubscriber subscriber, List<Notification> notifications) {
        try {
            try {
                subscriber.receiveBatchedNotifications(notifications);
            } catch (RuntimeException e) {
                // TODO >=8.0.0: safeguard code added in 7.0.0 transition; remove if never observed in 7.x cycle
                LOGGER.error("Unexpected RTE thrown from receiveBatchedNotifications()", e);
                throw new RemoteOperationException(e.toString());
            }
        } catch (RemoteOperationException e) {
            // not much information available, so use identity to tell subscribers apart in log
            int subscriberIdentity = System.identityHashCode(subscriber);
            Collection<NotificationTopic> subscribedTopics = getLocalSubscriberMetaData(subscriber).getSubscribedTopics();
            if (subscribedTopics.isEmpty()) {
                LOGGER.debug("Tried to remove subscriber " + subscriberIdentity
                    + " after a callback failure but it had no (or no more) topics to unsubscribe from; triggering error: " + e.toString());
            } else {
                for (NotificationTopic topic : subscribedTopics) {
                    unsubscribe(topic.getName(), subscriber);
                    LOGGER.debug("Removed subscriber " + subscriberIdentity + " from topic "
                        + topic.getName() + " after a callback failure: " + e.toString());
                }
            }
        }
    }

    private NotificationTopic registerNotificationTopic(String notificationId) {

        NotificationTopic topic = new NotificationTopic(notificationId);
        synchronized (topics) {
            topics.put(topic.getName(), topic);
        }
        currentNumbers.put(notificationId, new Long(NO_MISSED));
        return topic;
    }

    private NotificationTopic getNotificationTopic(String notificationId) {
        synchronized (topics) {
            return topics.get(notificationId);
        }
    }

    private LocalSubscriberMetaData getLocalSubscriberMetaData(NotificationSubscriber subscriber) {
        synchronized (subscriberMap) {
            LocalSubscriberMetaData metaData = subscriberMap.get(subscriber);
            if (metaData == null) {
                final BatchProcessor<Notification> batchProcessor = new NotificationBatchSender(subscriber);
                final BatchAggregator<Notification> batchAggregator =
                    ConcurrencyUtils.getFactory().createBatchAggregator(MAX_NOTIFICATION_BATCH_SIZE, MAX_NOTIFICATION_LATENCY,
                        batchProcessor);
                metaData = new LocalSubscriberMetaData(batchAggregator);
                subscriberMap.put(subscriber, metaData);
            }
            return metaData;
        }
    }

    private Set<NotificationTopic> getMatchingNotificationTopics(String currentNotificationId) {

        // TODO (p2) >8.0.0: this is performed on every send() call, and is quite CPU and GC intensive; rework approach
        Set<NotificationTopic> matchingTopics = new HashSet<NotificationTopic>();
        synchronized (topics) {
            for (NotificationTopic topic : topics.values()) {
                if (topic.getNotificationIdFilter().matcher(currentNotificationId).matches()) {
                    matchingTopics.add(topic);
                }
            }
            return matchingTopics;
        }
    }

    protected void awaitAsyncTaskCompletion() {
        if (FEATURE_FLAG_USE_ASYNCHRONOUS_SENDING) {
            final CountDownLatch latch = new CountDownLatch(1);
            deferredPublishingQueue.enqueue(new Runnable() {

                @Override
                public void run() {
                    latch.countDown();
                }
            });
            try {
                latch.await();
            } catch (InterruptedException e) {
                throw new RuntimeException("Interrupted while waiting for asynchronous tasks to complete", e);
            }
        }
    }

}
