/*
 * 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.artemis.core.server.group.impl;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import org.apache.activemq.artemis.api.core.SimpleString;
import org.apache.activemq.artemis.api.core.management.CoreNotificationType;
import org.apache.activemq.artemis.api.core.management.ManagementHelper;
import org.apache.activemq.artemis.core.postoffice.BindingType;
import org.apache.activemq.artemis.core.server.ActiveMQMessageBundle;
import org.apache.activemq.artemis.core.server.ActiveMQServerLogger;
import org.apache.activemq.artemis.core.server.management.ManagementService;
import org.apache.activemq.artemis.core.server.management.Notification;
import org.apache.activemq.artemis.utils.ExecutorFactory;
import org.apache.activemq.artemis.utils.collections.ConcurrentHashSet;
import org.apache.activemq.artemis.utils.collections.TypedProperties;

/**
 * A remote Grouping handler.
 * <p>
 * This will use management notifications to communicate with the node that has the Local Grouping handler to make
 * proposals.
 */
public final class RemoteGroupingHandler extends GroupHandlingAbstract {

   private final SimpleString name;

   private final Map<SimpleString, Response> responses = new ConcurrentHashMap<>();

   private final Lock lock = new ReentrantLock();

   private final Condition sendCondition = lock.newCondition();

   private final long timeout;

   private final long groupTimeout;

   private final ConcurrentMap<SimpleString, List<SimpleString>> groupMap = new ConcurrentHashMap<>();

   private final ConcurrentHashSet<Notification> pendingNotifications = new ConcurrentHashSet<>();

   private boolean started = false;

   public RemoteGroupingHandler(final ExecutorFactory executorFactory,
                                final ManagementService managementService,
                                final SimpleString name,
                                final SimpleString address,
                                final long timeout,
                                final long groupTimeout) {
      super(executorFactory != null ? executorFactory.getExecutor() : null, managementService, address);
      this.name = name;
      this.timeout = timeout;
      this.groupTimeout = groupTimeout;
   }

   public RemoteGroupingHandler(final ManagementService managementService,
                                final SimpleString name,
                                final SimpleString address,
                                final long timeout,
                                final long groupTimeout) {
      this(null, managementService, name, address, timeout, groupTimeout);
   }

   @Override
   public SimpleString getName() {
      return name;
   }

   @Override
   public void start() throws Exception {
      if (started)
         return;
      started = true;
   }

   @Override
   public void stop() throws Exception {
      started = false;
   }

   @Override
   public boolean isStarted() {
      return started;
   }

   @Override
   public void resendPending() throws Exception {
      // In case the RESET wasn't sent yet to the remote node, we may eventually miss a node send,
      // on that case the cluster-reset information will ask the group to resend any pending information

      try {
         lock.lock();

         for (Notification notification : pendingNotifications) {
            managementService.sendNotification(notification);
         }
      } finally {
         lock.unlock();
      }
   }

   @Override
   public Response propose(final Proposal proposal) throws Exception {
      // return it from the cache first
      Response response = responses.get(proposal.getGroupId());
      if (response != null) {
         checkTimeout(response);
         return response;
      }

      if (!started) {
         throw ActiveMQMessageBundle.BUNDLE.groupWhileStopping();
      }

      Notification notification = null;
      try {

         lock.lock();

         notification = createProposalNotification(proposal.getGroupId(), proposal.getClusterName());

         pendingNotifications.add(notification);

         managementService.sendNotification(notification);

         long timeLimit = System.currentTimeMillis() + timeout;

         do {
            sendCondition.await(timeout, TimeUnit.MILLISECONDS);

            response = responses.get(proposal.getGroupId());

            // You could have this response being null if you had multiple threads calling propose
            if (response != null) {
               break;
            }
         }
         while (timeLimit > System.currentTimeMillis());
      } finally {
         if (notification != null) {
            pendingNotifications.remove(notification);
         }
         lock.unlock();
      }
      if (response == null) {
         ActiveMQServerLogger.LOGGER.groupHandlerSendTimeout();
      }
      return response;
   }

   @Override
   public void awaitBindings() {
      // NO-OP
   }

   private void checkTimeout(Response response) {
      if (response != null) {
         if (groupTimeout > 0 && ((response.getTimeUsed() + groupTimeout) < System.currentTimeMillis())) {
            // We just touch the group on the local server at the half of the timeout
            // to avoid the group from expiring
            response.use();
            try {
               managementService.sendNotification(createProposalNotification(response.getGroupId(), response.getClusterName()));
            } catch (Exception ignored) {
            }
         }
      }
   }

   private Notification createProposalNotification(SimpleString groupId, SimpleString clusterName) {
      TypedProperties props = new TypedProperties();

      props.putSimpleStringProperty(ManagementHelper.HDR_PROPOSAL_GROUP_ID, groupId);

      props.putSimpleStringProperty(ManagementHelper.HDR_PROPOSAL_VALUE, clusterName);

      props.putIntProperty(ManagementHelper.HDR_BINDING_TYPE, BindingType.LOCAL_QUEUE_INDEX);

      props.putSimpleStringProperty(ManagementHelper.HDR_ADDRESS, address);

      props.putIntProperty(ManagementHelper.HDR_DISTANCE, 0);

      return new Notification(null, CoreNotificationType.PROPOSAL, props);
   }

   @Override
   public Response getProposal(final SimpleString fullID, boolean touchTime) {
      Response response = responses.get(fullID);

      if (touchTime) {
         checkTimeout(response);
      }
      return response;
   }

   @Override
   public void remove(SimpleString groupid, SimpleString clusterName) throws Exception {
      List<SimpleString> groups = groupMap.get(clusterName);
      if (groups != null) {
         groups.remove(groupid);
      }
      responses.remove(groupid);
      fireUnproposed(groupid);
   }

   @Override
   public void remove(SimpleString groupid, SimpleString clusterName, int distance) throws Exception {
      remove(groupid, clusterName);

      sendUnproposal(groupid, clusterName, distance);
   }

   @Override
   public void proposed(final Response response) throws Exception {
      try {
         lock.lock();
         responses.put(response.getGroupId(), response);
         List<SimpleString> newList = new ArrayList<>();
         List<SimpleString> oldList = groupMap.putIfAbsent(response.getChosenClusterName(), newList);
         if (oldList != null) {
            newList = oldList;
         }
         newList.add(response.getGroupId());
         // We could have more than one Requests waiting in case you have multiple producers
         // using different groups
         sendCondition.signalAll();
      } finally {
         lock.unlock();
      }
   }

   @Override
   public Response receive(final Proposal proposal, final int distance) throws Exception {
      TypedProperties props = new TypedProperties();
      props.putSimpleStringProperty(ManagementHelper.HDR_PROPOSAL_GROUP_ID, proposal.getGroupId());
      props.putSimpleStringProperty(ManagementHelper.HDR_PROPOSAL_VALUE, proposal.getClusterName());
      props.putIntProperty(ManagementHelper.HDR_BINDING_TYPE, BindingType.LOCAL_QUEUE_INDEX);
      props.putSimpleStringProperty(ManagementHelper.HDR_ADDRESS, address);
      props.putIntProperty(ManagementHelper.HDR_DISTANCE, distance);
      Notification notification = new Notification(null, CoreNotificationType.PROPOSAL, props);
      managementService.sendNotification(notification);
      return null;
   }

   @Override
   public void sendProposalResponse(final Response response, final int distance) throws Exception {
      // NO-OP
   }

   @Override
   public void addGroupBinding(final GroupBinding groupBinding) {
      // NO-OP
   }

   @Override
   public void onNotification(final Notification notification) {
      if (!(notification.getType() instanceof CoreNotificationType))
         return;
      // removing the groupid if the binding has been removed
      if (notification.getType() == CoreNotificationType.BINDING_REMOVED) {
         SimpleString clusterName = notification.getProperties().getSimpleStringProperty(ManagementHelper.HDR_CLUSTER_NAME);
         List<SimpleString> list = groupMap.remove(clusterName);
         if (list != null) {
            for (SimpleString val : list) {
               if (val != null) {
                  responses.remove(val);
               }
            }
         }
      }
   }
}
