package com.bazaarvoice.curator.recipes;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.imps.CuratorFrameworkState;
import org.apache.curator.framework.recipes.cache.ChildData;
import org.apache.curator.framework.recipes.cache.PathChildrenCache;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Closeable;
import java.io.IOException;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import static com.google.common.base.Preconditions.checkArgument;

/**
 * The {@code NodeDiscovery} class is used to watch a path in ZooKeeper. It will monitor which nodes
 * exist and fire node change events to subscribed instances of {@code NodeListener}. Users of this class should not
 * cache the results of discovery as subclasses can choose to change the set of available nodes based on some external
 * mechanism (ex. using bouncer).
 *
 * @param <T> The type that will be used to represent an active node.
 */
public class NodeDiscovery<T> implements Closeable {
    private static final Logger LOG = LoggerFactory.getLogger(NodeDiscovery.class);

    /** How long in milliseconds to wait between attempts to start. */
    private static final long WAIT_DURATION_IN_MILLIS = 100;

    private final ConcurrentMap<String, Optional<T>> _nodes;
    private final Set<NodeListener<T>> _listeners;
    private final CuratorFramework _curator;
    private final PathChildrenCache _pathCache;
    private final NodeDataParser<T> _nodeDataParser;
    private final ScheduledExecutorService _executor;
    private boolean _closed;

    /**
     * Creates an instance of {@code ZooKeeperNodeDiscovery}.
     *
     * @param curator    Curator framework reference.
     * @param nodePath   The path in ZooKeeper to watch.
     * @param parser     The strategy to convert from ZooKeeper {@code byte[]} to {@code T}.
     */
    public NodeDiscovery(CuratorFramework curator, String nodePath, NodeDataParser<T> parser) {
        Objects.requireNonNull(curator);
        Objects.requireNonNull(nodePath);
        Objects.requireNonNull(parser);
        checkArgument(curator.getState() == CuratorFrameworkState.STARTED);
        checkArgument(!"".equals(nodePath));

        ThreadFactory threadFactory = new ThreadFactoryBuilder()
                .setNameFormat(getClass().getSimpleName() + "(" + nodePath + ")-%d")
                .setDaemon(true)
                .build();

        _nodes = new ConcurrentHashMap<>();
        _listeners = Collections.newSetFromMap(new ConcurrentHashMap<>());
        _curator = curator;
        _executor = Executors.newSingleThreadScheduledExecutor(threadFactory);
        _pathCache = new PathChildrenCache(curator, nodePath, true, false, _executor);
        _nodeDataParser = parser;
        _closed = false;
    }

    /**
     * Start the NodeDiscovery.
     */
    public void start() {
        _pathCache.getListenable().addListener(new PathListener());
        startThenLoadData();
    }

    /**
     * Retrieve the available nodes.
     *
     * @return The available nodes.
     */
    public Map<String, T> getNodes() {
        return Maps.transformValues(Collections.unmodifiableMap(_nodes), input -> (input != null) ? input.orElse(null) : null);
    }

    /**
     * Returns true if the specified node is a member of the iterable returned by {@link #getNodes()}.
     *
     * @param node The node to test.
     * @return True if the specified node is a member of the iterable returned by {@link #getNodes()}.
     */
    public boolean contains(T node) {
        return _nodes.containsValue(Optional.ofNullable(node));
    }

    /**
     * Add a node listener.
     *
     * @param listener The node listener to add.
     */
    public void addListener(NodeListener<T> listener) {
        _listeners.add(listener);
    }

    /**
     * Remove a node listener.
     *
     * @param listener The node listener to remove.
     */
    public void removeListener(NodeListener<T> listener) {
        _listeners.remove(listener);
    }

    @Override
    public synchronized void close() throws IOException {
        if (!_closed) {
            _closed = true;
            _executor.shutdown();
            _listeners.clear();
            _pathCache.close();
            _nodes.clear();
        }
    }

    @VisibleForTesting
    CuratorFramework getCurator() {
        return _curator;
    }

    /**
     * Start the underlying path cache and then populate the data for any nodes that existed prior to being created and
     * connected to ZooKeeper.
     * <p/>
     * This must be synchronized so async remove events aren't processed between start() and adding nodes.
     * Use synchronous start(true) instead of asynchronous start(false) so we can tell when it's done and the
     * node discovery set is usable.
     * <p/>
     * If there is a problem starting the path cache then we'll continue attempting to start it in a background thread
     * until the node discovery is closed.
     */
    private synchronized void startThenLoadData() {
        if (_closed) {
            return;
        }

        try {
            _pathCache.start(PathChildrenCache.StartMode.BUILD_INITIAL_CACHE);
        } catch (Throwable t) {
            waitThenStartAgain();
            return;
        }

        loadExistingData();
    }

    /**
     * Wait a short period of time then try to start the path cache again.
     */
    private void waitThenStartAgain() {
        _executor.schedule(this::startThenLoadData, WAIT_DURATION_IN_MILLIS, TimeUnit.MILLISECONDS);
    }

    /**
     * Loads all of the existing data from the underlying path cache.
     */
    private synchronized void loadExistingData() {
        for (ChildData childData : _pathCache.getCurrentData()) {
            addNode(childData.getPath(), parseChildData(childData));
        }
    }

    private synchronized void addNode(String path, T node) {
        // synchronize the modification of _nodes and firing of events so listeners always receive events in the
        // order they occur.
        if (_nodes.put(path, Optional.ofNullable(node)) == null) {
            fireAddEvent(path, node);
        }
    }

    private synchronized void removeNode(String path, T node) {
        // synchronize the modification of _nodes and firing of events so listeners always receive events in the
        // order they occur.
        if (_nodes.remove(path) != null) {
            fireRemoveEvent(path, node);
        }
    }

    private synchronized void updateNode(String path, T node) {
        // synchronize the modification of _nodes and firing of events so listeners always receive events in the
        // order they occur.
        Optional<T> oldNode = _nodes.put(path, Optional.ofNullable(node));
        if (!Objects.equals(oldNode.orElse(null), node)) {
            fireUpdateEvent(path, node);
        }
    }

    private void fireAddEvent(String path, T node) {
        for (NodeListener<T> listener : _listeners) {
            listener.onNodeAdded(path, node);
        }
    }

    private void fireRemoveEvent(String path, T node) {
        for (NodeListener<T> listener : _listeners) {
            listener.onNodeRemoved(path, node);
        }
    }

    private void fireUpdateEvent(String path, T node) {
        for (NodeListener<T> listener : _listeners) {
            listener.onNodeUpdated(path, node);
        }
    }

    private T parseChildData(ChildData childData) {
        T value = null;
        try {
            value = _nodeDataParser.parse(childData.getPath(), childData.getData());
        } catch (Exception e) {
            LOG.warn("NodeDataParser failed to parse ZooKeeper data. ZooKeeperPath: {}; Exception Message: {}",
                    childData.getPath(), e.getMessage());
            LOG.warn("Exception", e);
        }

        return value;
    }

    /**
     * A curator <code>PathChildrenCacheListener</code>
     */
    private final class PathListener implements PathChildrenCacheListener {
        @Override
        public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) {
            String nodePath = null;
            T nodeData = null;
            if (event.getData() != null) {
                nodePath = event.getData().getPath();
                nodeData = parseChildData(event.getData());
            }
            switch (event.getType()) {
                case CHILD_ADDED:
                    addNode(nodePath, nodeData);
                    break;

                case CHILD_REMOVED:
                    removeNode(nodePath, nodeData);
                    break;

                case CHILD_UPDATED:
                    updateNode(nodePath, nodeData);
                    break;
            }
        }
    }

    /**
     * The {@code NodeDataParser} class is used to encapsulate the strategy that converts ZooKeeper node data into
     * a logical format for the user of {@code NodeDiscovery}.
     */
    public interface NodeDataParser<T> {
        T parse(String path, byte[] nodeData);
    }

    /** Listener interface that is notified when nodes are added, removed, or updated. */
    public interface NodeListener<T> {
        void onNodeAdded(String path, T node);
        void onNodeRemoved(String path, T node);
        void onNodeUpdated(String path, T node);
    }
}