/**
 * 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.activemq.usecases;

import javax.jms.Connection;
import javax.jms.ConnectionFactory;
import javax.jms.DeliveryMode;
import javax.jms.Destination;
import javax.jms.JMSException;
import javax.jms.Message;
import javax.jms.MessageConsumer;
import javax.jms.MessageListener;
import javax.jms.MessageProducer;
import javax.jms.Session;
import javax.jms.TextMessage;
import javax.jms.TopicSubscriber;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

import org.apache.activemq.ActiveMQConnectionFactory;
import org.apache.activemq.ActiveMQPrefetchPolicy;
import org.apache.activemq.TestSupport;
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.broker.region.policy.StorePendingDurableSubscriberMessageStoragePolicy;
import org.apache.activemq.command.MessageId;
import org.apache.activemq.util.MessageIdList;
import org.apache.activemq.util.Wait;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@RunWith(value = Parameterized.class)
public class ConcurrentProducerDurableConsumerTest extends TestSupport {

   private static final Logger LOG = LoggerFactory.getLogger(ConcurrentProducerDurableConsumerTest.class);
   private final int consumerCount = 5;
   BrokerService broker;
   protected List<Connection> connections = Collections.synchronizedList(new ArrayList<Connection>());
   protected Map<MessageConsumer, TimedMessageListener> consumers = new HashMap<>();
   protected MessageIdList allMessagesList = new MessageIdList();
   private final int messageSize = 1024;

   private final TestSupport.PersistenceAdapterChoice persistenceAdapterChoice;

   @Parameterized.Parameters
   public static Collection<TestSupport.PersistenceAdapterChoice[]> getTestParameters() {
      TestSupport.PersistenceAdapterChoice[] kahaDb = {TestSupport.PersistenceAdapterChoice.KahaDB};
      TestSupport.PersistenceAdapterChoice[] levelDb = {TestSupport.PersistenceAdapterChoice.LevelDB};
      TestSupport.PersistenceAdapterChoice[] mem = {TestSupport.PersistenceAdapterChoice.MEM};
      List<TestSupport.PersistenceAdapterChoice[]> choices = new ArrayList<>();
      choices.add(kahaDb);
      choices.add(levelDb);
      choices.add(mem);
      return choices;
   }

   public ConcurrentProducerDurableConsumerTest(TestSupport.PersistenceAdapterChoice choice) {
      this.persistenceAdapterChoice = choice;
   }

   @Test(timeout = 120000)
   public void testSendRateWithActivatingConsumers() throws Exception {
      final Destination destination = createDestination();
      final ConnectionFactory factory = createConnectionFactory();
      startInactiveConsumers(factory, destination);

      Connection connection = factory.createConnection();
      Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
      MessageProducer producer = createMessageProducer(session, destination);

      // preload the durable consumers
      double[] inactiveConsumerStats = produceMessages(destination, 500, 10, session, producer, null);
      LOG.info("With inactive consumers: ave: " + inactiveConsumerStats[1] + ", max: " + inactiveConsumerStats[0] + ", multiplier: " + (inactiveConsumerStats[0] / inactiveConsumerStats[1]));

      // periodically start a durable sub that has a backlog
      final int consumersToActivate = 5;
      final CountDownLatch addConsumerSignal = new CountDownLatch(1);
      Executors.newCachedThreadPool(new ThreadFactory() {
         @Override
         public Thread newThread(Runnable r) {
            return new Thread(r, "ActivateConsumer" + this);
         }
      }).execute(new Runnable() {
         @Override
         public void run() {
            try {
               MessageConsumer consumer = null;
               for (int i = 0; i < consumersToActivate; i++) {
                  LOG.info("Waiting for add signal from producer...");
                  addConsumerSignal.await(30, TimeUnit.MINUTES);
                  TimedMessageListener listener = new TimedMessageListener();
                  consumer = createDurableSubscriber(factory.createConnection(), destination, "consumer" + (i + 1));
                  LOG.info("Created consumer " + consumer);
                  consumer.setMessageListener(listener);
                  consumers.put(consumer, listener);
               }
            } catch (Exception e) {
               LOG.error("failed to start consumer", e);
            }
         }
      });

      double[] statsWithActive = produceMessages(destination, 500, 10, session, producer, addConsumerSignal);

      LOG.info(" with concurrent activate, ave: " + statsWithActive[1] + ", max: " + statsWithActive[0] + ", multiplier: " + (statsWithActive[0] / statsWithActive[1]));

      while (consumers.size() < consumersToActivate) {
         TimeUnit.SECONDS.sleep(2);
      }

      long timeToFirstAccumulator = 0;
      for (TimedMessageListener listener : consumers.values()) {
         long time = listener.getFirstReceipt();
         timeToFirstAccumulator += time;
         LOG.info("Time to first " + time);
      }
      LOG.info("Ave time to first message =" + timeToFirstAccumulator / consumers.size());

      for (TimedMessageListener listener : consumers.values()) {
         LOG.info("Ave batch receipt time: " + listener.waitForReceivedLimit(10000) + " max receipt: " + listener.maxReceiptTime);
      }

      //assertTrue("max (" + statsWithActive[0] + ") within reasonable multiplier of ave (" + statsWithActive[1] + ")",
      //        statsWithActive[0] < 5 * statsWithActive[1]);

      // compare no active to active
      LOG.info("Ave send time with active: " + statsWithActive[1] + " as multiplier of ave with none active: " + inactiveConsumerStats[1] + ", multiplier=" + (statsWithActive[1] / inactiveConsumerStats[1]));

      assertTrue("Ave send time with active: " + statsWithActive[1] + " within reasonable multpler of ave with none active: " + inactiveConsumerStats[1] + ", multiplier " + (statsWithActive[1] / inactiveConsumerStats[1]), statsWithActive[1] < 15 * inactiveConsumerStats[1]);
   }

   public void x_testSendWithInactiveAndActiveConsumers() throws Exception {
      Destination destination = createDestination();
      ConnectionFactory factory = createConnectionFactory();
      startInactiveConsumers(factory, destination);

      Connection connection = factory.createConnection();
      Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE);
      MessageProducer producer = session.createProducer(destination);
      producer.setDeliveryMode(DeliveryMode.PERSISTENT);

      final int toSend = 100;
      final int numIterations = 5;

      double[] noConsumerStats = produceMessages(destination, toSend, numIterations, session, producer, null);

      startConsumers(factory, destination);
      LOG.info("Activated consumer");

      double[] withConsumerStats = produceMessages(destination, toSend, numIterations, session, producer, null);

      LOG.info("With consumer: " + withConsumerStats[1] + " , with noConsumer: " + noConsumerStats[1] + ", multiplier: " + (withConsumerStats[1] / noConsumerStats[1]));
      final int reasonableMultiplier = 15; // not so reasonable but improving
      assertTrue("max X times as slow with consumer: " + withConsumerStats[1] + ", with no Consumer: " + noConsumerStats[1] + ", multiplier: " + (withConsumerStats[1] / noConsumerStats[1]), withConsumerStats[1] < noConsumerStats[1] * reasonableMultiplier);

      final int toReceive = toSend * numIterations * consumerCount * 2;
      Wait.waitFor(new Wait.Condition() {
         @Override
         public boolean isSatisified() throws Exception {
            LOG.info("count: " + allMessagesList.getMessageCount());
            return toReceive == allMessagesList.getMessageCount();
         }
      }, 60 * 1000);

      assertEquals("got all messages", toReceive, allMessagesList.getMessageCount());
   }

   private MessageProducer createMessageProducer(Session session, Destination destination) throws JMSException {
      MessageProducer producer = session.createProducer(destination);
      producer.setDeliveryMode(DeliveryMode.PERSISTENT);
      return producer;
   }

   private void startInactiveConsumers(ConnectionFactory factory, Destination destination) throws Exception {
      // create off line consumers
      startConsumers(factory, destination);
      for (Connection connection : connections) {
         connection.close();
      }
      connections.clear();
      consumers.clear();
   }

   protected void startConsumers(ConnectionFactory factory, Destination dest) throws Exception {
      MessageConsumer consumer;
      for (int i = 0; i < consumerCount; i++) {
         TimedMessageListener list = new TimedMessageListener();
         consumer = createDurableSubscriber(factory.createConnection(), dest, "consumer" + (i + 1));
         consumer.setMessageListener(list);
         consumers.put(consumer, list);
      }
   }

   protected TopicSubscriber createDurableSubscriber(Connection conn, Destination dest, String name) throws Exception {
      conn.setClientID(name);
      connections.add(conn);
      conn.start();

      Session sess = conn.createSession(false, Session.AUTO_ACKNOWLEDGE);
      final TopicSubscriber consumer = sess.createDurableSubscriber((javax.jms.Topic) dest, name);

      return consumer;
   }

   /**
    * @return max and ave send time
    * @throws Exception
    */
   private double[] produceMessages(Destination destination,
                                    final int toSend,
                                    final int numIterations,
                                    Session session,
                                    MessageProducer producer,
                                    CountDownLatch addConsumerSignal) throws Exception {
      long start;
      long count = 0;
      double batchMax = 0, max = 0, sum = 0;
      for (int i = 0; i < numIterations; i++) {
         start = System.currentTimeMillis();
         for (int j = 0; j < toSend; j++) {
            long singleSendstart = System.currentTimeMillis();
            TextMessage msg = createTextMessage(session, "" + j);
            // rotate
            int priority = ((int) count % 10);
            producer.send(msg, DeliveryMode.PERSISTENT, priority, 0);
            max = Math.max(max, (System.currentTimeMillis() - singleSendstart));
            if (++count % 500 == 0) {
               if (addConsumerSignal != null) {
                  addConsumerSignal.countDown();
                  LOG.info("Signalled add consumer");
               }
            }
            if (count % 5000 == 0) {
               LOG.info("Sent " + count + ", singleSendMax:" + max);
            }

         }
         long duration = System.currentTimeMillis() - start;
         batchMax = Math.max(batchMax, duration);
         sum += duration;
         LOG.info("Iteration " + i + ", sent " + toSend + ", time: " + duration + ", batchMax:" + batchMax + ", singleSendMax:" + max);
      }

      LOG.info("Sent: " + toSend * numIterations + ", batchMax: " + batchMax + " singleSendMax: " + max);
      return new double[]{batchMax, sum / numIterations};
   }

   protected TextMessage createTextMessage(Session session, String initText) throws Exception {
      TextMessage msg = session.createTextMessage();

      // Pad message text
      if (initText.length() < messageSize) {
         char[] data = new char[messageSize - initText.length()];
         Arrays.fill(data, '*');
         String str = new String(data);
         msg.setText(initText + str);

         // Do not pad message text
      } else {
         msg.setText(initText);
      }

      return msg;
   }

   @Override
   @Before
   public void setUp() throws Exception {
      topic = true;
      super.setUp();
      broker = createBroker();
      broker.start();
   }

   @Override
   @After
   public void tearDown() throws Exception {
      for (Iterator<Connection> iter = connections.iterator(); iter.hasNext(); ) {
         Connection conn = iter.next();
         try {
            conn.close();
         } catch (Throwable e) {
         }
      }
      broker.stop();
      allMessagesList.flushMessages();
      consumers.clear();
      super.tearDown();
   }

   protected BrokerService createBroker() throws Exception {
      BrokerService brokerService = new BrokerService();
      brokerService.setEnableStatistics(false);
      brokerService.addConnector("tcp://0.0.0.0:0");
      brokerService.setDeleteAllMessagesOnStartup(true);

      PolicyEntry policy = new PolicyEntry();
      policy.setPrioritizedMessages(true);
      policy.setMaxPageSize(500);

      StorePendingDurableSubscriberMessageStoragePolicy durableSubPending = new StorePendingDurableSubscriberMessageStoragePolicy();
      durableSubPending.setImmediatePriorityDispatch(true);
      durableSubPending.setUseCache(true);
      policy.setPendingDurableSubscriberPolicy(durableSubPending);

      PolicyMap policyMap = new PolicyMap();
      policyMap.setDefaultEntry(policy);
      brokerService.setDestinationPolicy(policyMap);

      //        if (false) {
      //            // external mysql works a lot faster
      //            //
      //            JDBCPersistenceAdapter jdbc = new JDBCPersistenceAdapter();
      //            BasicDataSource ds = new BasicDataSource();
      //            com.mysql.jdbc.Driver d = new com.mysql.jdbc.Driver();
      //            ds.setDriverClassName("com.mysql.jdbc.Driver");
      //            ds.setUrl("jdbc:mysql://localhost/activemq?relaxAutoCommit=true");
      //            ds.setMaxActive(200);
      //            ds.setUsername("root");
      //            ds.setPassword("");
      //            ds.setPoolPreparedStatements(true);
      //            jdbc.setDataSource(ds);
      //            brokerService.setPersistenceAdapter(jdbc);

            /* add mysql bits to the pom in the testing dependencies
                    <dependency>
                        <groupId>mysql</groupId>
                        <artifactId>mysql-connector-java</artifactId>
                        <version>5.1.10</version>
                        <scope>test</scope>
                    </dependency>
                    <dependency>
                        <groupId>commons-dbcp</groupId>
                        <artifactId>commons-dbcp</artifactId>
                        <version>1.2.2</version>
                        <scope>test</scope>
                    </dependency>
             */
      //        } else {
      setPersistenceAdapter(brokerService, persistenceAdapterChoice);
      //        }
      return brokerService;
   }

   @Override
   protected ActiveMQConnectionFactory createConnectionFactory() throws Exception {
      ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory(broker.getTransportConnectors().get(0).getPublishableConnectString());
      ActiveMQPrefetchPolicy prefetchPolicy = new ActiveMQPrefetchPolicy();
      prefetchPolicy.setAll(1);
      factory.setPrefetchPolicy(prefetchPolicy);

      factory.setDispatchAsync(true);
      return factory;
   }

   class TimedMessageListener implements MessageListener {

      final int batchSize = 1000;
      CountDownLatch firstReceiptLatch = new CountDownLatch(1);
      long mark = System.currentTimeMillis();
      long firstReceipt = 0L;
      long receiptAccumulator = 0;
      long batchReceiptAccumulator = 0;
      long maxReceiptTime = 0;
      AtomicLong count = new AtomicLong(0);
      Map<Integer, MessageIdList> messageLists = new ConcurrentHashMap<>(new HashMap<Integer, MessageIdList>());

      @Override
      public void onMessage(Message message) {
         final long current = System.currentTimeMillis();
         final long duration = current - mark;
         receiptAccumulator += duration;
         int priority = 0;
         try {
            priority = message.getJMSPriority();
         } catch (JMSException ignored) {
         }
         if (!messageLists.containsKey(priority)) {
            MessageIdList perPriorityList = new MessageIdList();
            perPriorityList.setParent(allMessagesList);
            messageLists.put(priority, perPriorityList);
         }
         messageLists.get(priority).onMessage(message);
         if (count.incrementAndGet() == 1) {
            firstReceipt = duration;
            firstReceiptLatch.countDown();
            LOG.info("First receipt in " + firstReceipt + "ms");
         } else if (count.get() % batchSize == 0) {
            LOG.info("Consumed " + count.get() + " in " + batchReceiptAccumulator + "ms" + ", priority:" + priority);
            batchReceiptAccumulator = 0;
         }
         maxReceiptTime = Math.max(maxReceiptTime, duration);
         receiptAccumulator += duration;
         batchReceiptAccumulator += duration;
         mark = current;
      }

      long getMessageCount() {
         return count.get();
      }

      long getFirstReceipt() throws Exception {
         firstReceiptLatch.await(30, TimeUnit.SECONDS);
         return firstReceipt;
      }

      public long waitForReceivedLimit(long limit) throws Exception {
         final long expiry = System.currentTimeMillis() + 30 * 60 * 1000;
         while (count.get() < limit) {
            if (System.currentTimeMillis() > expiry) {
               throw new RuntimeException("Expired waiting for X messages, " + limit);
            }
            TimeUnit.SECONDS.sleep(2);
            String missing = findFirstMissingMessage();
            if (missing != null) {
               LOG.info("first missing = " + missing);
               throw new RuntimeException("We have a missing message. " + missing);
            }

         }
         return receiptAccumulator / (limit / batchSize);
      }

      private String findFirstMissingMessage() {
         MessageId current = new MessageId();
         for (MessageIdList priorityList : messageLists.values()) {
            MessageId previous = null;
            for (String id : priorityList.getMessageIds()) {
               current.setValue(id);
               if (previous == null) {
                  previous = current.copy();
               } else {
                  if (current.getProducerSequenceId() - 1 != previous.getProducerSequenceId() && current.getProducerSequenceId() - 10 != previous.getProducerSequenceId()) {
                     return "Missing next after: " + previous + ", got: " + current;
                  } else {
                     previous = current.copy();
                  }
               }
            }
         }
         return null;
      }
   }
}
