/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.ignite.stream.mqtt;

import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import org.apache.activemq.broker.BrokerService;
import org.apache.activemq.broker.region.policy.PolicyEntry;
import org.apache.activemq.broker.region.policy.PolicyMap;
import org.apache.activemq.command.ActiveMQTopic;
import org.apache.ignite.Ignite;
import org.apache.ignite.IgniteCache;
import org.apache.ignite.IgniteDataStreamer;
import org.apache.ignite.cache.CachePeekMode;
import org.apache.ignite.events.CacheEvent;
import org.apache.ignite.internal.util.lang.GridMapEntry;
import org.apache.ignite.internal.util.typedef.F;
import org.apache.ignite.lang.IgniteBiPredicate;
import org.apache.ignite.lang.IgniteInClosure;
import org.apache.ignite.stream.StreamMultipleTupleExtractor;
import org.apache.ignite.stream.StreamSingleTupleExtractor;
import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;

import com.github.rholder.retry.StopStrategies;
import com.github.rholder.retry.WaitStrategies;
import com.google.common.base.Splitter;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.eclipse.paho.client.mqttv3.MqttMessage;
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence;
import org.junit.After;
import org.junit.Before;

import static org.apache.ignite.events.EventType.EVT_CACHE_OBJECT_PUT;

/**
 * Test for {@link MqttStreamer}.
 */
public class IgniteMqttStreamerTest extends GridCommonAbstractTest {
    /** The test data. */
    private static final Map<Integer, String> TEST_DATA = new HashMap<>();

    /** Topic name for single topic tests. */
    private static final String SINGLE_TOPIC_NAME = "abc";

    /** Topic names for multiple topic tests. */
    private static final List<String> MULTIPLE_TOPIC_NAMES = Arrays.asList("def", "ghi", "jkl", "mno");

    /** The AMQ broker with an MQTT interface. */
    private BrokerService broker;

    /** The MQTT client. */
    private MqttClient client;

    /** The broker URL. */
    private String brokerUrl;

    /** The broker port. **/
    private int port;

    /** The MQTT streamer currently under test. */
    private MqttStreamer<Integer, String> streamer;

    /** The UUID of the currently active remote listener. */
    private UUID remoteLsnr;

    /** The Ignite data streamer. */
    private IgniteDataStreamer<Integer, String> dataStreamer;

    static {
        for (int i = 0; i < 100; i++)
            TEST_DATA.put(i, "v" + i);
    }

    /** Constructor. */
    public IgniteMqttStreamerTest() {
        super(true);
    }

    /**
     * @throws Exception If failed.
     */
    @Before
    @SuppressWarnings("unchecked")
    public void beforeTest() throws Exception {
        grid().<Integer, String>getOrCreateCache(defaultCacheConfiguration());

        // find an available local port
        try (ServerSocket ss = new ServerSocket(0)) {
            port = ss.getLocalPort();
        }

        // create the broker
        broker = new BrokerService();
        broker.setDeleteAllMessagesOnStartup(true);
        broker.setPersistent(false);
        broker.setPersistenceAdapter(null);
        broker.setPersistenceFactory(null);

        PolicyMap plcMap = new PolicyMap();
        PolicyEntry plc = new PolicyEntry();

        plc.setQueuePrefetch(1);

        broker.setDestinationPolicy(plcMap);
        broker.getDestinationPolicy().setDefaultEntry(plc);
        broker.setSchedulerSupport(false);

        // add the MQTT transport connector to the broker
        broker.addConnector("mqtt://localhost:" + port);
        broker.setStartAsync(false);
        broker.start(true);

        // create the broker URL
        brokerUrl = "tcp://localhost:" + port;

        // create the client and connect
        client = new MqttClient(brokerUrl, UUID.randomUUID().toString(), new MemoryPersistence());

        client.connect();

        // create mqtt streamer
        dataStreamer = grid().dataStreamer(DEFAULT_CACHE_NAME);

        streamer = createMqttStreamer(dataStreamer);
    }

    /**
     * @throws Exception If failed.
     */
    @After
    public void afterTest() throws Exception {
        try {
            streamer.stop();
        }
        catch (Exception ignored) {
            // ignore if already stopped
        }

        dataStreamer.close();

        grid().cache(DEFAULT_CACHE_NAME).clear();

        broker.stop();
        broker.deleteAllMessages();
    }

    /**
     * @throws Exception If failed.
     */
    public void testConnectDisconnect() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopic(SINGLE_TOPIC_NAME);
        streamer.setBlockUntilConnected(true);

        // action time: repeat 10 times; make sure the connection state is kept correctly every time
        for (int i = 0; i < 10; i++) {
            streamer.start();

            assertTrue(streamer.isConnected());

            streamer.stop();

            assertFalse(streamer.isConnected());
        }
    }

    /**
     * @throws Exception If failed.
     */
    public void testConnectionStatusWithBrokerDisconnection() throws Exception {
        fail("https://issues.apache.org/jira/browse/IGNITE-2255");

        // Configure streamer.
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopic(SINGLE_TOPIC_NAME);
        streamer.setBlockUntilConnected(true);
        streamer.setRetryWaitStrategy(WaitStrategies.noWait());

        streamer.start();

        // Action time: repeat 5 times; make sure the connection state is kept correctly every time.
        for (int i = 0; i < 5; i++) {
            log.info("Iteration: " + i);

            assertTrue(streamer.isConnected());

            broker.stop();

            assertFalse(streamer.isConnected());

            broker.start(true);
            broker.waitUntilStarted();

            Thread.sleep(500);
        }
    }

    /**
     * @throws Exception If failed.
     */
    public void testSingleTopic_NoQoS_OneEntryPerMessage() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopic(SINGLE_TOPIC_NAME);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 0, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);
    }

    /**
     * @throws Exception If failed.
     */
    public void testMultipleTopics_NoQoS_OneEntryPerMessage() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopics(MULTIPLE_TOPIC_NAMES);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(MULTIPLE_TOPIC_NAMES, 0, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);

        assertTrue(broker.getBroker().getDestinationMap().size() >= 4);
        assertTrue(broker.getBroker().getDestinationMap().containsKey(new ActiveMQTopic("def")));
        assertTrue(broker.getBroker().getDestinationMap().containsKey(new ActiveMQTopic("ghi")));
    }

    /**
     * @throws Exception If failed.
     */
    public void testSingleTopic_NoQoS_MultipleEntriesOneMessage() throws Exception {
        // configure streamer
        streamer.setMultipleTupleExtractor(multipleTupleExtractor());
        streamer.setTopic(SINGLE_TOPIC_NAME);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 0, 50, true);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);
    }

    /**
     * @throws Exception If failed.
     */
    public void testMultipleTopics_NoQoS_MultipleEntriesOneMessage() throws Exception {
        // configure streamer
        streamer.setMultipleTupleExtractor(multipleTupleExtractor());
        streamer.setTopics(MULTIPLE_TOPIC_NAMES);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(MULTIPLE_TOPIC_NAMES, 0, 50, true);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);

        assertTrue(broker.getBroker().getDestinationMap().size() >= 4);
        assertTrue(broker.getBroker().getDestinationMap().containsKey(new ActiveMQTopic("def")));
        assertTrue(broker.getBroker().getDestinationMap().containsKey(new ActiveMQTopic("ghi")));
    }

    /**
     * @throws Exception If failed.
     */
    public void testSingleTopic_NoQoS_ConnectOptions_Durable() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopic(SINGLE_TOPIC_NAME);

        MqttConnectOptions connOptions = new MqttConnectOptions();
        connOptions.setCleanSession(false);
        streamer.setConnectOptions(connOptions);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 0, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);

        // explicitly stop the streamer
        streamer.stop();

        // send messages while stopped
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 50, 50, false);

        latch = subscribeToPutEvents(50);

        // start the streamer again
        streamer.start();

        // assertions - make sure that messages sent during disconnection were also received
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(100);
    }

    /**
     * @throws Exception If failed.
     */
    public void testSingleTopic_NoQoS_Reconnect() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setRetryWaitStrategy(WaitStrategies.noWait());
        streamer.setRetryStopStrategy(StopStrategies.neverStop());
        streamer.setTopic(SINGLE_TOPIC_NAME);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 0, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);

        // now shutdown the broker, wait 2 seconds and start it again
        broker.stop();
        broker.start(true);
        broker.waitUntilStarted();

        Thread.sleep(2000);

        client.connect();

        // let's ensure we have 2 connections: Ignite and our test
        assertEquals(2, broker.getTransportConnectorByScheme("mqtt").getConnections().size());

        // subscribe to cache PUT events again
        latch = subscribeToPutEvents(50);

        // send messages
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 50, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(100);
    }

    /**
     * @throws Exception If failed.
     */
    public void testSingleTopic_NoQoS_RetryOnce() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setRetryWaitStrategy(WaitStrategies.noWait());
        streamer.setRetryStopStrategy(StopStrategies.stopAfterAttempt(1));
        streamer.setTopic(SINGLE_TOPIC_NAME);

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 0, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);

        // now shutdown the broker, wait 2 seconds and start it again
        broker.stop();
        broker.start(true);

        broker.waitUntilStarted();

        client.connect();

        // lets send messages and ensure they are not received, because our retrier desisted
        sendMessages(Arrays.asList(SINGLE_TOPIC_NAME), 50, 50, false);

        Thread.sleep(3000);

        assertNull(grid().cache(DEFAULT_CACHE_NAME).get(50));
    }

    /**
     * @throws Exception If failed.
     */
    public void testMultipleTopics_MultipleQoS_OneEntryPerMessage() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopics(MULTIPLE_TOPIC_NAMES);
        streamer.setQualitiesOfService(Arrays.asList(1, 1, 1, 1));

        // subscribe to cache PUT events
        CountDownLatch latch = subscribeToPutEvents(50);

        // action time
        streamer.start();

        // send messages
        sendMessages(MULTIPLE_TOPIC_NAMES, 0, 50, false);

        // assertions
        assertTrue(latch.await(10, TimeUnit.SECONDS));
        assertCacheEntriesLoaded(50);

        assertTrue(broker.getBroker().getDestinationMap().size() >= 4);
        assertTrue(broker.getBroker().getDestinationMap().containsKey(new ActiveMQTopic("def")));
        assertTrue(broker.getBroker().getDestinationMap().containsKey(new ActiveMQTopic("ghi")));
    }

    /**
     * @throws Exception If failed.
     */
    public void testMultipleTopics_MultipleQoS_Mismatch() throws Exception {
        // configure streamer
        streamer.setSingleTupleExtractor(singleTupleExtractor());
        streamer.setTopics(MULTIPLE_TOPIC_NAMES);
        streamer.setQualitiesOfService(Arrays.asList(1, 1, 1));

        try {
            streamer.start();
        }
        catch (Exception ignored) {
            return;
        }

        fail("Expected an exception reporting invalid parameters");
    }

    /**
     * @param dataStreamer Streamer.
     * @return MQTT streamer.
     */
    private MqttStreamer<Integer, String> createMqttStreamer(IgniteDataStreamer<Integer, String> dataStreamer) {
        MqttStreamer<Integer, String> streamer = new MqttStreamer<>();
        streamer.setIgnite(grid());
        streamer.setStreamer(dataStreamer);
        streamer.setBrokerUrl(brokerUrl);
        streamer.setClientId(UUID.randomUUID().toString());
        streamer.setBlockUntilConnected(true);

        dataStreamer.allowOverwrite(true);
        dataStreamer.autoFlushFrequency(1);

        return streamer;
    }

    /**
     * @param topics Topics.
     * @param fromIdx From index.
     * @param cnt Count.
     * @param singleMsg Single message flag.
     * @throws MqttException If failed.
     */
    private void sendMessages(final List<String> topics, int fromIdx, int cnt, boolean singleMsg)
        throws MqttException {
        if (singleMsg) {
            final List<StringBuilder> sbs = new ArrayList<>(topics.size());

            // initialize String Builders for each topic
            F.forEach(topics, new IgniteInClosure<String>() {
                @Override public void apply(String s) {
                    sbs.add(new StringBuilder());
                }
            });

            // fill String Builders for each topic
            F.forEach(F.range(fromIdx, fromIdx + cnt), new IgniteInClosure<Integer>() {
                @Override public void apply(Integer integer) {
                    sbs.get(integer % topics.size()).append(integer.toString() + "," + TEST_DATA.get(integer) + "\n");
                }
            });

            // send each buffer out
            for (int i = 0; i < topics.size(); i++) {
                MqttMessage msg = new MqttMessage(sbs.get(i).toString().getBytes());

                client.publish(topics.get(i % topics.size()), msg);
            }
        }
        else {
            for (int i = fromIdx; i < fromIdx + cnt; i++) {
                byte[] payload = (i + "," + TEST_DATA.get(i)).getBytes();

                MqttMessage msg = new MqttMessage(payload);

                client.publish(topics.get(i % topics.size()), msg);
            }
        }
    }

    /**
     * @param expect Expected count.
     * @return Latch to be counted down in listener.
     */
    private CountDownLatch subscribeToPutEvents(int expect) {
        Ignite ignite = grid();

        // Listen to cache PUT events and expect as many as messages as test data items
        final CountDownLatch latch = new CountDownLatch(expect);

        IgniteBiPredicate<UUID, CacheEvent> cb = new IgniteBiPredicate<UUID, CacheEvent>() {
            @Override public boolean apply(UUID uuid, CacheEvent evt) {
                latch.countDown();

                return true;
            }
        };

        remoteLsnr = ignite.events(ignite.cluster().forCacheNodes(DEFAULT_CACHE_NAME))
            .remoteListen(cb, null, EVT_CACHE_OBJECT_PUT);

        return latch;
    }

    /**
     * @param cnt Count.
     */
    private void assertCacheEntriesLoaded(int cnt) {
        // get the cache and check that the entries are present
        IgniteCache<Integer, String> cache = grid().cache(DEFAULT_CACHE_NAME);

        // for each key from 0 to count from the TEST_DATA (ordered by key), check that the entry is present in cache
        for (Integer key : new ArrayList<>(new TreeSet<>(TEST_DATA.keySet())).subList(0, cnt))
            assertEquals(TEST_DATA.get(key), cache.get(key));

        // assert that the cache exactly the specified amount of elements
        assertEquals(cnt, cache.size(CachePeekMode.ALL));

        // remove the event listener
        grid().events(grid().cluster().forCacheNodes(DEFAULT_CACHE_NAME)).stopRemoteListen(remoteLsnr);
    }

    /**
     * @return {@link StreamSingleTupleExtractor} for testing.
     */
    public static StreamSingleTupleExtractor<MqttMessage, Integer, String> singleTupleExtractor() {
        return new StreamSingleTupleExtractor<MqttMessage, Integer, String>() {
            @Override public Map.Entry<Integer, String> extract(MqttMessage msg) {
                List<String> s = Splitter.on(",").splitToList(new String(msg.getPayload()));

                return new GridMapEntry<>(Integer.parseInt(s.get(0)), s.get(1));
            }
        };
    }

    /**
     * @return {@link StreamMultipleTupleExtractor} for testing.
     */
    public static StreamMultipleTupleExtractor<MqttMessage, Integer, String> multipleTupleExtractor() {
        return new StreamMultipleTupleExtractor<MqttMessage, Integer, String>() {
            @Override public Map<Integer, String> extract(MqttMessage msg) {
                final Map<String, String> map = Splitter.on("\n")
                    .omitEmptyStrings()
                    .withKeyValueSeparator(",")
                    .split(new String(msg.getPayload()));

                final Map<Integer, String> answer = new HashMap<>();

                F.forEach(map.keySet(), new IgniteInClosure<String>() {
                    @Override public void apply(String s) {
                        answer.put(Integer.parseInt(s), map.get(s));
                    }
                });

                return answer;
            }
        };
    }

}
