/*
 * JBoss, Home of Professional Open Source
 * Copyright 2005, JBoss Inc., and individual contributors as indicated
 * by the @authors tag. See the copyright.txt in the distribution for a
 * full listing of individual contributors.
 *
 * This is free software; you can redistribute it and/or modify it
 * under the terms of the GNU Lesser General Public License as
 * published by the Free Software Foundation; either version 2.1 of
 * the License, or (at your option) any later version.
 *
 * This software is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this software; if not, write to the Free
 * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
 * 02110-1301 USA, or see the FSF site: http://www.fsf.org.
 */
package org.jboss.ws.extensions.eventing.mgmt;

// $Id: SubscriptionManager.java 9921 2009-04-29 10:58:27Z richard.opalka@jboss.com $

import java.io.PrintWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

import javax.management.MBeanServer;
import javax.management.MBeanServerFactory;
import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.xml.XMLConstants;
import javax.xml.bind.JAXBElement;
import javax.xml.namespace.QName;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;

import org.apache.xml.utils.DefaultErrorHandler;
import org.jboss.logging.Logger;
import org.jboss.util.naming.Util;
import org.jboss.ws.WSException;
import org.jboss.ws.core.utils.UUIDGenerator;
import org.jboss.ws.extensions.eventing.EventingConstants;
import org.jboss.ws.extensions.eventing.deployment.EventingEndpointDeployment;
import org.jboss.ws.extensions.eventing.jaxws.AttributedURIType;
import org.jboss.ws.extensions.eventing.jaxws.EndpointReferenceType;
import org.jboss.ws.extensions.eventing.jaxws.ReferenceParametersType;
import org.jboss.wsf.common.DOMUtils;
import org.jboss.wsf.common.DOMWriter;
import org.w3c.dom.Element;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.SAXParseException;

/**
 * The SubscriptionManagerEndpoint maintains event sources and subscriptions.<br>
 * It is interfaced through the EventSourceEndpoint and SubscriptionManagerEndpoint SOAP endpoints.
 * <p>
 * Applications can use the EventDispatcher interface to dispatch
 * event messages to subscribers. The current implementation is backed by a ThreadPoolExecutor,
 * that asynchronously delivers messages to event sink endpoints.
 * It can be configurd through the following attributes:
 * <ul>
 *  <li>corePoolSize - average number of idle threads
 *  <li>maximumPoolSize - maximum number of threads
 *  <li>eventKeepAlive - keep alive before an undelivered event message is discarded.
 * </ul>
 * Event filtering is supported on subscription level based on XPath expressions.
 * For further information see <code>http://www.w3.org/TR/xpath#predicates</code>.
 * <p>
 * Currently only event push is supported.
 *
 * @see org.jboss.ws.extensions.eventing.jaxws.EventSourceEndpoint
 * @see org.jboss.ws.extensions.eventing.jaxws.SubscriptionManagerEndpoint
 * @see org.jboss.ws.extensions.eventing.jaxws.FilterType
 *
 * @author Heiko Braun, <heiko@openj.net>
 * @since 02-Dec-2005
 */
public class SubscriptionManager implements SubscriptionManagerMBean, EventDispatcher
{
   private static final Logger log = Logger.getLogger(SubscriptionManager.class);

   // Maps event source namespaces to event source instances.
   private ConcurrentMap<URI, EventSource> eventSourceMapping = new ConcurrentHashMap<URI, EventSource>();
   // Maps subscriptions to event sources
   private ConcurrentMap<URI, List<Subscription>> subscriptionMapping = new ConcurrentHashMap<URI, List<Subscription>>();
   // Buffers notifications. FIFO ordering.
   private BlockingQueue<Runnable> eventQueue = new LinkedBlockingQueue<Runnable>();
   // Event dispatcher thread pool.
   private ThreadPoolExecutor threadPool;
   // True force validation of every notification message against its schema
   private boolean validateNotifications = false;
   // subscription watchdog that maintains expirations
   private WatchDog watchDog;
   // True if dispatcher is bound to JNDI
   private boolean isDispatcherBound = false;
   // List containing all errors occured during notification since service startup
   // TODO: save this list and made possible to resend failed notification using jms instead of list
   private List<NotificationFailure> notificationFailures = new ArrayList<NotificationFailure>();
   // The host that the dispatcher is bound to
   private String bindAddress;

   private static EventingBuilder builder = EventingBuilder.createEventingBuilder();

   public String getBindAddress()
   {
      if (bindAddress == null)
      {
         try
         {
            InetAddress localHost = InetAddress.getLocalHost();
            log.debug("BindAddress not set, using host: " + localHost.getHostName());
            bindAddress = localHost.getHostName();
         }
         catch (UnknownHostException e)
         {
            log.debug("BindAddress not set, using: 'localhost'");
            bindAddress = "localhost";
         }
      }
      return bindAddress;
   }

   public void setBindAddress(String bindAddress)
   {
      this.bindAddress = bindAddress;
   }

   public void create() throws Exception
   {
      MBeanServer server = getJMXServer();
      if (server != null)
      {
         log.debug("Create subscription manager");
         server.registerMBean(this, OBJECT_NAME);
      }
   }

   public void destroy() throws Exception
   {
      MBeanServer server = getJMXServer();
      if (server != null)
      {
         log.debug("Destroy subscription manager");
         server.unregisterMBean(OBJECT_NAME);
      }
   }

   public void start() throws Exception
   {
      log.debug("Start subscription manager");

      // setup thread pool
      threadPool = new ThreadPoolExecutor(5, 15, // core/max num threads
            5000, TimeUnit.MILLISECONDS, // 5 seconds keepalive
            eventQueue);

      // start the subscription watchdog
      watchDog = new WatchDog(subscriptionMapping);
      watchDog.startup();
   }

   public void stop()
   {
      log.debug("Stop subscription manager");
      try
      {
         // remove event dispatcher
         Util.unbind(new InitialContext(), EventingConstants.DISPATCHER_JNDI_NAME);

         // stop thread pool
         threadPool.shutdown();

         // stop the watchdog
         watchDog.shutdown();

         for (URI eventSourceNS : eventSourceMapping.keySet())
         {
            removeEventSource(eventSourceNS);
         }
      }
      catch (NamingException e)
      {
         // ignore
      }
   }

   private static URI generateSubscriptionID()
   {
      try
      {
         return new URI("urn:jbwse:" + UUIDGenerator.generateRandomUUIDString());
      }
      catch (URISyntaxException e)
      {
         throw new WSException(e.getMessage());
      }
   }

   /**
    * A two phase deployment process.
    */
   public void registerEventSource(EventingEndpointDeployment deploymentInfo)
   {
      // workaround for JBWS-1006
      lazyBindEventDispatcher();

      EventSource eventSource = builder.newEventSource(deploymentInfo);
      if (eventSourceMapping.containsKey(eventSource.getNameSpace()) == false)
      {
         eventSourceMapping.put(eventSource.getNameSpace(), eventSource);
         updateManagerAddress(deploymentInfo, eventSource);

         eventSource.setState(EventSource.State.CREATED);
         log.debug("Created: " + eventSource);
      }
      else
      {
         eventSource = eventSourceMapping.get(eventSource.getNameSpace());
         updateManagerAddress(deploymentInfo, eventSource);
         subscriptionMapping.put(eventSource.getNameSpace(), new CopyOnWriteArrayList<Subscription>());

         eventSource.setState(EventSource.State.STARTED);
         log.debug("Started: " + eventSource);
      }
   }

   private void lazyBindEventDispatcher()
   {
      if (!isDispatcherBound)
      {
         try
         {
            // bind dispatcher to JNDI
            Util.rebind(new InitialContext(), EventingConstants.DISPATCHER_JNDI_NAME, new DispatcherDelegate(getBindAddress()));
            log.info("Bound event dispatcher to java:/" + EventingConstants.DISPATCHER_JNDI_NAME);
            isDispatcherBound = true;
         }
         catch (NamingException e)
         {
            throw new WSException("Unable to bind EventDispatcher ", e);
         }
      }
   }

   /**
    * When both the EventSourcePort and the SubscriptionManagerPort are registered
    * we finally know the real SubscriptionManager EPR and update the endpoint adress.
    * @param deploymentInfo
    * @param eventSource
    */
   private static void updateManagerAddress(EventingEndpointDeployment deploymentInfo, EventSource eventSource)
   {
      String addr = null;
      if (deploymentInfo.getPortName().getLocalPart().equals("SubscriptionManagerPort"))
         addr = deploymentInfo.getEndpointAddress();

      if (addr != null)
         eventSource.setManagerAddress(addr);
   }

   public void removeEventSource(URI eventSourceNS)
   {
      if (eventSourceMapping.containsKey(eventSourceNS))
      {
         List<Subscription> subscriptions = subscriptionMapping.get(eventSourceNS);
         for (Subscription s : subscriptions) // iterator is a snapshot
         {
            s.end(EventingConstants.SOURCE_SHUTTING_DOWN);
         }

         subscriptions.clear();
         eventSourceMapping.remove(eventSourceNS);
         log.debug("Event source " + eventSourceNS + " removed");
      }
   }

   /**
    * Subscribe to an event source.
    */
   public SubscriptionTicket subscribe(URI eventSourceNS, EndpointReferenceType notifyTo, EndpointReferenceType endTo, Date expires, Filter filter)
         throws SubscriptionError
   {
      log.debug("Subscription request for " + eventSourceNS);

      EventSource eventSource = eventSourceMapping.get(eventSourceNS);
      if (null == eventSource)
         throw new SubscriptionError(EventingConstants.CODE_UNABLE_TO_PROCESS, "EventSource '" + eventSourceNS + "' not registered");

      // expiry constraints
      if (expires != null)
      {
         assertLeaseConstraints(expires);
      }
      else
      {
         expires = new Date((System.currentTimeMillis() + EventingConstants.DEFAULT_LEASE));
      }

      // filter constraints
      if (filter != null)
      {
         if (eventSource.getSupportedFilterDialects().isEmpty())
            throw new SubscriptionError(EventingConstants.CODE_FILTER_NOT_SUPPORTED, "Filtering is not supported.");
         else
         {
            boolean filterAvailable = false;
            for (URI supportedDialect : eventSource.getSupportedFilterDialects())
            {
               if (filter.getDialect().equals(supportedDialect))
               {
                  filterAvailable = true;
                  break;
               }
            }

            if (!filterAvailable)
               throw new SubscriptionError(EventingConstants.CODE_REQUESTED_FILTER_UNAVAILABLE, "The requested filter dialect is not supported.");
         }
      }

      // create subscription
      EndpointReferenceType epr = new EndpointReferenceType();
      AttributedURIType attrURI = new AttributedURIType();
      attrURI.setValue(eventSource.getManagerAddress().toString());
      epr.setAddress(attrURI);
      ReferenceParametersType refParam = new ReferenceParametersType();
      JAXBElement idqn = new JAXBElement(new QName("http://schemas.xmlsoap.org/ws/2004/08/eventing", "Identifier"), String.class, generateSubscriptionID().toString());
      refParam.getAny().add(idqn);
      epr.setReferenceParameters(refParam);

      Subscription subscription = new Subscription(eventSource.getNameSpace(), epr, notifyTo, endTo, expires, filter);

      subscriptionMapping.get(eventSourceNS).add(subscription);

      log.debug("Registered subscription " + subscription.getIdentifier());

      return new SubscriptionTicket(epr, subscription.getExpires());
   }

   private void assertLeaseConstraints(Date expireDate) throws SubscriptionError
   {
      long expires = expireDate.getTime() - System.currentTimeMillis();
      if (expires < 0 || EventingConstants.MAX_LEASE_TIME < expires)
         throw new SubscriptionError(EventingConstants.CODE_INVALID_EXPIRATION_TIME, "The expiration time requested is invalid: " + expires + "ms");
   }

   /**
    * Renew a subscription.
    *
    * @param identifier
    * @param lease
    * @return the new lease date
    * @throws SubscriptionError
    */
   public Date renew(URI identifier, Date lease) throws SubscriptionError
   {
      Subscription subscription = subscriberForID(identifier);
      if (null == subscription)
         throw new SubscriptionError(EventingConstants.CODE_UNABLE_TO_RENEW, "Subscription " + identifier + " does not exist");

      if (lease != null)
         assertLeaseConstraints(lease);
      else lease = new Date((System.currentTimeMillis() + EventingConstants.DEFAULT_LEASE));

      subscription.setExpires(lease);
      return lease;
   }

   /**
    * Get status for subscription.
    *
    * @param identifier
    * @return the actual lease date.
    * @throws SubscriptionError when the subscriber does not exist
    */
   public final Date getStatus(URI identifier) throws SubscriptionError
   {
      Subscription subscription = subscriberForID(identifier);
      if (null == subscription)
         throw new SubscriptionError(EventingConstants.CODE_UNABLE_TO_PROCESS, "Subscription " + identifier + " does not exist");

      return subscription.getExpires();
   }

   /**
    * Release a subscription.
    *
    * @param identifier
    * @throws SubscriptionError when the subscriber does not exist
    */
   public void unsubscribe(URI identifier) throws SubscriptionError
   {
      for (List<Subscription> subscriptions : subscriptionMapping.values())
      {
         for (Subscription s : subscriptions)
         {
            if (identifier.equals(s.getIdentifier()))
            {
               subscriptions.remove(s);
               log.debug("Removed subscription " + s);
               break;
            }
         }
      }
   }

   public String showEventsourceTable()
   {

      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw);

      pw.println("<h3>Deployed Eventsources</h3>");

      pw.println("<table>");
      pw.println("<tr><td>Name</td><td>NS</td></tr>");

      for (EventSource source : eventSourceMapping.values())
      {
         pw.println("<tr><td>" + source.getName() + "</td><td>" + source.getNameSpace() + "</td></tr>");
      }

      pw.println("</table>");
      pw.close();

      return sw.toString();
   }

   public String showSubscriptionTable()
   {
      StringWriter sw = new StringWriter();
      PrintWriter pw = new PrintWriter(sw);

      pw.println("<h3>Registered Subscriptions</h3>");

      pw.println("<table>");
      pw.println("<tr><td>Identifier</td><td>Expires</td><td>Filter</td></tr>");

      for (List<Subscription> subscriptions : subscriptionMapping.values())
      {
         for (Subscription s : subscriptions)
         {
            pw.println("<tr><td>" + s.getIdentifier() + "</td><td>" + s.getExpires() + "</td><td>" + s.getFilter().getExpression() + "</td></tr>");
         }
      }
      pw.println("</table>");
      pw.close();

      return sw.toString();
   }

   private Subscription subscriberForID(URI id)
   {
      Subscription subscription = null;
      for (List<Subscription> subscriptions : subscriptionMapping.values())
      {
         for (Subscription s : subscriptions)
         {
            if (id.equals(s.getIdentifier()))
            {
               subscription = s;
               break;
            }
         }
      }
      return subscription;
   }

   public void dispatch(URI eventSourceNS, Element payload)
   {
      DispatchJob dispatchJob = new DispatchJob(eventSourceNS, payload, subscriptionMapping);
      if (validateNotifications && !this.validateMessage(DOMWriter.printNode(payload, false), eventSourceNS))
      {
         throw new DispatchException("Notification message validation failed!");
      }
      threadPool.execute(dispatchJob);
   }

   public void addNotificationFailure(NotificationFailure failure)
   {
      notificationFailures.add(failure);
   }

   public List<NotificationFailure> showNotificationFailures()
   {
      return notificationFailures;
   }

   private boolean validateMessage(String msg, URI eventSourceNS)
   {
      try
      {
         EventSource es = eventSourceMapping.get(eventSourceNS);
         log.info(new StringBuffer("Validating message: \n\n").append(msg).append("\n\nagainst the following schema(s): \n").toString());
         for (int i = 0; i < es.getNotificationSchema().length; i++)
         {
            log.info(es.getNotificationSchema()[i]);
         }
         Element rootElement = DOMUtils.parse(msg);
         if (!es.getNotificationRootElementNS().equalsIgnoreCase(rootElement.getNamespaceURI()))
         {
            log.error("Root element expected namespace: " + es.getNotificationRootElementNS());
            return false;
         }
         DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
         factory.setNamespaceAware(true);
         factory.setValidating(true);
         factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
         factory.setAttribute("http://java.sun.com/xml/jaxp/properties/schemaLanguage", "http://www.w3.org/2001/XMLSchema");

         String[] notificationSchemas = es.getNotificationSchema();
         InputSource[] is = new InputSource[notificationSchemas.length];
         for (int i = 0; i < notificationSchemas.length; i++)
         {
            is[i] = new InputSource(new StringReader(notificationSchemas[notificationSchemas.length - 1 - i]));
            //is[i] = new InputSource(new StringReader(notificationSchemas[i]));
         }

         factory.setAttribute("http://java.sun.com/xml/jaxp/properties/schemaSource", is);
         DocumentBuilder docBuilder = factory.newDocumentBuilder();
         DefaultErrorHandler errorHandler = new Validator();
         docBuilder.setErrorHandler(errorHandler);
         docBuilder.parse(new InputSource(new StringReader(msg)));
         log.info("Document validated!");
         return true;
      }
      catch (Exception e)
      {
         log.error(e);
         log.info("Cannot validate and/or parse the document!");
         return false;
      }

   }

   // ----------------------------------------------------------------------
   // MBean support

   public int getCorePoolSize()
   {
      return threadPool.getCorePoolSize();
   }

   public int getMaximumPoolSize()
   {
      return threadPool.getMaximumPoolSize();
   }

   public int getLargestPoolSize()
   {
      return threadPool.getLargestPoolSize();
   }

   public int getActiveCount()
   {
      return threadPool.getActiveCount();
   }

   public long getCompletedTaskCount()
   {
      return threadPool.getCompletedTaskCount();
   }

   public void setCorePoolSize(int corePoolSize)
   {
      threadPool.setCorePoolSize(corePoolSize);
   }

   public void setMaxPoolSize(int maxPoolSize)
   {
      threadPool.setMaximumPoolSize(maxPoolSize);
   }

   public void setEventKeepAlive(long millies)
   {
      threadPool.setKeepAliveTime(millies, TimeUnit.MILLISECONDS);
   }

   public boolean isValidateNotifications()
   {
      return this.validateNotifications;
   }

   public void setValidateNotifications(boolean validateNotifications)
   {
      this.validateNotifications = validateNotifications;
   }

   private MBeanServer getJMXServer()
   {
      MBeanServer server = null;
      ArrayList<MBeanServer> servers = MBeanServerFactory.findMBeanServer(null);
      for (MBeanServer current : servers)
      {
         if (current.getClass().getName().startsWith("org.jboss")) // JBWS-2580
         {
            server = current;
            break;
         }
      }

      return server;
   }

   /**
    * The watchdog maintains subscription expirations.
    */
   private class WatchDog implements Runnable
   {

      private ConcurrentMap<URI, List<Subscription>> subscriptions;
      private boolean active = true;
      private Thread worker;

      public WatchDog(ConcurrentMap<URI, List<Subscription>> subscriptions)
      {
         this.subscriptions = subscriptions;
      }

      public void run()
      {
         while (active)
         {

            for (List<Subscription> subscriptions : subscriptionMapping.values())
            {
               for (Subscription s : subscriptions)
               {
                  if (s.isExpired())
                  {
                     s.end(EventingConstants.SOURCE_CANCELING);
                     subscriptions.remove(s);
                  }
               }
            }

            try
            {
               Thread.sleep(1000 * 60);
            }
            catch (InterruptedException e)
            {
               log.error(e);
            }
         }
      }

      public void startup()
      {
         worker = new Thread(this, "SubscriptionWatchDog");
         worker.start();
      }

      public void shutdown()
      {
         this.active = false;
      }

   }

   private class Validator extends DefaultErrorHandler
   {
      public void error(SAXParseException exception) throws SAXException
      {
         throw new SAXException(exception);
      }

      public void fatalError(SAXParseException exception) throws SAXException
      {
         throw new SAXException(exception);
      }

      public void warning(SAXParseException exception) throws SAXException
      {
      }
   }

}
