/*
 * 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.deployment.scanner;

import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.StringTokenizer;

import javax.management.MBeanServer;
import javax.management.ObjectName;

import org.jboss.deployment.DefaultDeploymentSorter;
import org.jboss.deployment.IncompleteDeploymentException;
import org.jboss.mx.util.JMXExceptionDecoder;
import org.jboss.net.protocol.URLLister;
import org.jboss.net.protocol.URLListerFactory;
import org.jboss.net.protocol.URLLister.URLFilter;
import org.jboss.system.server.ServerConfig;
import org.jboss.system.server.ServerConfigLocator;
import org.jboss.util.NullArgumentException;
import org.jboss.util.StringPropertyReplacer;

/**
 * A URL-based deployment scanner.  Supports local directory
 * scanning for file-based urls.
 *
 * @jmx:mbean extends="org.jboss.deployment.scanner.DeploymentScannerMBean"
 *
 * @version <tt>$Revision$</tt>
 * @author  <a href="mailto:jason@planet57.com">Jason Dillon</a>
 * @author <a href="mailto:dimitris@jboss.org">Dimitris Andreadis</a>
 */
public class URLDeploymentScanner extends AbstractDeploymentScanner
   implements DeploymentScanner, URLDeploymentScannerMBean
{
   /** A set of deployment URLs to skip **/
   protected Set skipSet = Collections.synchronizedSet(new HashSet());
   
   /** The list of URLs to scan. */
   protected List urlList = Collections.synchronizedList(new ArrayList());

   /** A set of scanned urls which have been deployed. */
   protected Set deployedSet = Collections.synchronizedSet(new HashSet());

   /** Helper for listing local/remote directory URLs */
   protected URLListerFactory listerFactory = new URLListerFactory();
   
   /** The server's home directory, for relative paths. */
   protected File serverHome;

   protected URL serverHomeURL;

   /** A sorter urls from a scaned directory to allow for coarse dependency
    ordering based on file type
    */
   protected Comparator sorter;

   /** Allow a filter for scanned directories */
   protected URLFilter filter;
   
   protected IncompleteDeploymentException lastIncompleteDeploymentException;
   
   /** Whether to search inside directories whose names containing no dots */
   protected boolean doRecursiveSearch = true;
   
   /**
    * @jmx:managed-attribute
    */
   public void setRecursiveSearch (boolean recurse)
   {
      doRecursiveSearch = recurse;
   }
   
   /**
    * @jmx:managed-attribute
    */
   public boolean getRecursiveSearch ()
   {
      return doRecursiveSearch;
   }
   
   /**
    * @jmx:managed-attribute
    */
   public void setURLList(final List list)
   {
      if (list == null)
         throw new NullArgumentException("list");

      // start out with a fresh list
      urlList.clear();

      Iterator iter = list.iterator();
      while (iter.hasNext())
      {
         URL url = (URL)iter.next();
         if (url == null)
            throw new NullArgumentException("list element");

         addURL(url);
      }
      
      log.debug("URL list: " + urlList);
   }

   /**
    * @jmx:managed-attribute
    *
    * @param classname    The name of a Comparator class.
    */
   public void setURLComparator(String classname)
      throws ClassNotFoundException, IllegalAccessException,
      InstantiationException
   {
      sorter = (Comparator)Thread.currentThread().getContextClassLoader().loadClass(classname).newInstance();
   }

   /**
    * @jmx:managed-attribute
    */
   public String getURLComparator()
   {
      if (sorter == null)
         return null;
      return sorter.getClass().getName();
   }

   /**
    * @jmx:managed-attribute
    *
    * @param classname    The name of a FileFilter class.
    */
   public void setFilter(String classname)
   throws ClassNotFoundException, IllegalAccessException, InstantiationException
   {
      Class filterClass = Thread.currentThread().getContextClassLoader().loadClass(classname);
      filter = (URLFilter) filterClass.newInstance();
   }

   /**
    * @jmx:managed-attribute
    */
   public String getFilter()
   {
      if (filter == null)
         return null;
      return filter.getClass().getName();
   }

   
   /**
    * @jmx:managed-attribute
    *
    * @param filter The URLFilter instance
    */
   public void setFilterInstance(URLFilter filter)
   {
      this.filter = filter;
   }

   /**
    * @jmx:managed-attribute
    */
   public URLFilter getFilterInstance()
   {
      return filter;
   }

   /**
    * @jmx:managed-attribute
    */
   public List getURLList()
   {
      // too bad, List isn't a cloneable
      return new ArrayList(urlList);
   }

   /**
    * @jmx:managed-operation
    */
   public void addURL(final URL url)
   {
      if (url == null)
         throw new NullArgumentException("url");
      
      try
      {
         // check if this is a valid url
         url.openConnection().connect();
      }
      catch (IOException e)
      {
         // either a bad configuration (non-existent url) or a transient i/o error
         log.warn("addURL(), caught " + e.getClass().getName() + ": " + e.getMessage());
      }
      urlList.add(url);
      
      log.debug("Added url: " + url);
   }

   /**
    * @jmx:managed-operation
    */
   public void removeURL(final URL url)
   {
      if (url == null)
         throw new NullArgumentException("url");

      boolean success = urlList.remove(url);
      if (success)
      {
         log.debug("Removed url: " + url);
      }
   }

   /**
    * @jmx:managed-operation
    */
   public boolean hasURL(final URL url)
   {
      if (url == null)
         throw new NullArgumentException("url");

      return urlList.contains(url);
   }

   /**
    * Temporarily ignore changes (addition, updates, removal) to a particular
    * deployment, identified by its deployment URL. The deployment URL is different
    * from the 'base' URLs that are scanned by the scanner (e.g. the full path to
    * deploy/jmx-console.war vs. deploy/). This can be used to avoid an attempt
    * by the scanner to deploy/redeploy/undeploy a URL that is being modified.
    * 
    * To re-enable scanning of changes for a URL, use resumeDeployment(URL, boolean).
    *
    * @jmx:managed-operation
    */
   public void suspendDeployment(URL url)
   {
      if (url == null)
         throw new NullArgumentException("url");
      
      if (skipSet.add(url))
         log.debug("Deployment URL added to skipSet: " + url);
      else
         throw new IllegalStateException("Deployment URL already suspended: " + url);
   }

   /**
    * Re-enables scanning of a particular deployment URL, previously suspended
    * using suspendDeployment(URL). If the markUpToDate flag is true then the
    * deployment module will be considered up-to-date during the next scan.
    * If the flag is false, at the next scan the scanner will check the
    * modification date to decide if the module needs deploy/redeploy/undeploy.
    *
    * @jmx:managed-operation
    */
   public void resumeDeployment(URL url, boolean markUpToDate)
   {
      if (url == null)
         throw new NullArgumentException("url");
      
      if (skipSet.contains(url))
      {
         if (markUpToDate)
         {
            // look for the deployment and mark it as uptodate
            for (Iterator i = deployedSet.iterator(); i.hasNext(); )
            {
               DeployedURL deployedURL = (DeployedURL)i.next();
               if (deployedURL.url.equals(url))
               {
                  // the module could have been removed..
                  log.debug("Marking up-to-date: " + url);                  
                  deployedURL.deployed();
                  break;
               }
            }
         }
         // don't skip this url anymore
         skipSet.remove(url);
         log.debug("Deployment URL removed from skipSet: " + url);
      }
      else
      {
         throw new IllegalStateException("Deployment URL not suspended: " + url);
      }
   }

   /**
    * Lists all urls deployed by the scanner, each URL on a new line.
    *
    * @jmx:managed-operation
    */
   public String listDeployedURLs()
   {
      StringBuffer sbuf = new StringBuffer();
      for (Iterator i = deployedSet.iterator(); i.hasNext(); )
      {
         URL url = ((DeployedURL)i.next()).url;
         if (sbuf.length() > 0)
         {
            sbuf.append("\n").append(url);
         } 
         else 
         {
            sbuf.append(url);
         }
      }
      return sbuf.toString();
   }

   /////////////////////////////////////////////////////////////////////////
   //                  Management/Configuration Helpers                   //
   /////////////////////////////////////////////////////////////////////////

   /**
    * @jmx:managed-attribute
    */
   public void setURLs(final String listspec) throws MalformedURLException
   {
      if (listspec == null)
         throw new NullArgumentException("listspec");

      List list = new LinkedList();

      StringTokenizer stok = new StringTokenizer(listspec, ",");
      while (stok.hasMoreTokens())
      {
         String urlspec = stok.nextToken().trim();
         log.debug("Adding URL from spec: " + urlspec);

         URL url = makeURL(urlspec);
         log.debug("URL: " + url);
         
         list.add(url);
      }

      setURLList(list);
   }

   /**
    * A helper to make a URL from a full url, or a filespec.
    */
   protected URL makeURL(String urlspec) throws MalformedURLException
   {
      // First replace URL with appropriate properties
      //
      urlspec = StringPropertyReplacer.replaceProperties (urlspec);
      return new URL(serverHomeURL, urlspec);
   }

   /**
    * @jmx:managed-operation
    */
   public void addURL(final String urlspec) throws MalformedURLException
   {
      addURL(makeURL(urlspec));
   }

   /**
    * @jmx:managed-operation
    */
   public void removeURL(final String urlspec) throws MalformedURLException
   {
      removeURL(makeURL(urlspec));
   }

   /**
    * @jmx:managed-operation
    */
   public boolean hasURL(final String urlspec) throws MalformedURLException
   {
      return hasURL(makeURL(urlspec));
   }

   /**
    * A helper to deploy the given URL with the deployer.
    */
   protected void deploy(final DeployedURL du)
   {
      // If the deployer is null simply ignore the request
      if (deployer == null)
         return;
      
      try
      {
         if (log.isTraceEnabled())
            log.trace("Deploying: " + du);

         deployer.deploy(du.url);
      }
      catch (IncompleteDeploymentException e)
      {
         lastIncompleteDeploymentException = e;
      }
      catch (Exception e)
      {
         log.debug("Failed to deploy: " + du, e);
      }

      du.deployed();

      if (!deployedSet.contains(du))
      {
         deployedSet.add(du);
      }
   }

   /**
    * A helper to undeploy the given URL from the deployer.
    */
   protected void undeploy(final DeployedURL du)
   {
      try
      {
         if (log.isTraceEnabled())
            log.trace("Undeploying: " + du);

         deployer.undeploy(du.url);
         deployedSet.remove(du);
      }
      catch (Exception e)
      {
         log.error("Failed to undeploy: " + du, e);
      }
   }

   /**
    * Checks if the url is in the deployed set.
    */
   protected boolean isDeployed(final URL url)
   {
      DeployedURL du = new DeployedURL(url);
      return deployedSet.contains(du);
   }

   public synchronized void scan() throws Exception
   {
      lastIncompleteDeploymentException = null;
      if (urlList == null)
         throw new IllegalStateException("not initialized");

      updateSorter();

      boolean trace = log.isTraceEnabled();
      List urlsToDeploy = new LinkedList();

      // Scan for deployments
      if (trace)
      {
         log.trace("Scanning for new deployments");
      }
      synchronized (urlList)
      {
         for (Iterator i = urlList.iterator(); i.hasNext();)
         {
            URL url = (URL) i.next();
            try
            {
               if (url.toString().endsWith("/"))
               {
                  // treat URL as a collection
                  URLLister lister = listerFactory.createURLLister(url);
                  
                  // listMembers() will throw an IOException if collection url does not exist
                  urlsToDeploy.addAll(lister.listMembers(url, filter, doRecursiveSearch));
               } 
               else
               {
                  // treat URL as a deployable unit
                  
                  // throws IOException if this URL does not exist
                  url.openConnection().connect();
                  urlsToDeploy.add(url);
               }
            }
            catch (IOException e)
            {
               // Either one of the configured URLs is bad, i.e. points to a non-existent
               // location, or it ends with a '/' but it is not a directory (so it
               // is really user's fault), OR some other hopefully transient I/O error
               // happened (e.g. out of file descriptors?) so log a warning.
               log.warn("Scan URL, caught " + e.getClass().getName() + ": " + e.getMessage());
               
               // We need to return because at least one of the listed URLs will
               // return no results, and so all deployments starting from that point
               // (e.g. deploy/) will get undeployed, see JBAS-3107.
               // On the other hand, in case of a bad configuration nothing will get
               // deployed. If really want independence of e.g. 2 deploy urls, more
               // than one URLDeploymentScanners can be setup.
               return;
            }
         }
      }

      if (trace)
      {
         log.trace("Updating existing deployments");
      }
      LinkedList urlsToRemove = new LinkedList();
      LinkedList urlsToCheckForUpdate = new LinkedList();
      synchronized (deployedSet)
      {
         // remove previously deployed URLs no longer needed
         for (Iterator i = deployedSet.iterator(); i.hasNext();)
         {
            DeployedURL deployedURL = (DeployedURL) i.next();
            
            if (skipSet.contains(deployedURL.url))
            {
               if (trace)
                  log.trace("Skipping update/removal check for: " + deployedURL.url);
            }
            else
            {
               if (urlsToDeploy.contains(deployedURL.url))
               {
                  urlsToCheckForUpdate.add(deployedURL);
               }
               else
               {
                  urlsToRemove.add(deployedURL);
               }
            }
         }
      }

      // ********
      // Undeploy
      // ********

      for (Iterator i = urlsToRemove.iterator(); i.hasNext();)
      {
         DeployedURL deployedURL = (DeployedURL) i.next();
         if (trace)
         {
            log.trace("Removing " + deployedURL.url);
         }
         undeploy(deployedURL);
      }

      // ********
      // Redeploy
      // ********

      // compute the DeployedURL list to update
      ArrayList urlsToUpdate = new ArrayList(urlsToCheckForUpdate.size());
      for (Iterator i = urlsToCheckForUpdate.iterator(); i.hasNext();)
      {
         DeployedURL deployedURL = (DeployedURL) i.next();
         if (deployedURL.isModified())
         {
            if (trace)
            {
               log.trace("Re-deploying " + deployedURL.url);
            }
            urlsToUpdate.add(deployedURL);
         }
      }

      // sort to update list
      Collections.sort(urlsToUpdate, new Comparator()
      {
         public int compare(Object o1, Object o2)
         {
            return sorter.compare(((DeployedURL) o1).url, ((DeployedURL) o2).url);
         }
      });

      // Undeploy in order
      for (int i = urlsToUpdate.size() - 1; i >= 0;i--)
      {
         undeploy((DeployedURL) urlsToUpdate.get(i));
      }

      // Deploy in order
      for (int i = 0; i < urlsToUpdate.size();i++)
      {
         deploy((DeployedURL) urlsToUpdate.get(i));
      }

      // ******
      // Deploy
      // ******

      Collections.sort(urlsToDeploy, sorter);
      for (Iterator i = urlsToDeploy.iterator(); i.hasNext();)
      {
         URL url = (URL) i.next();
         DeployedURL deployedURL = new DeployedURL(url);
         if (deployedSet.contains(deployedURL) == false)
         {
            if (skipSet.contains(url))
            {
               if (trace)
                  log.trace("Skipping deployment of: " + url);
            }
            else
            {
               if (trace)
                  log.trace("Deploying " + deployedURL.url);
               
               deploy(deployedURL);
            }
         }
         i.remove();
         // Check to see if mainDeployer suffix list has changed.
         // if so, then resort
         if (i.hasNext() && updateSorter())
         {
            Collections.sort(urlsToDeploy, sorter);
            i = urlsToDeploy.iterator();
         }
      }

      // Validate that there are still incomplete deployments
      if (lastIncompleteDeploymentException != null)
      {
         try
         {
            Object[] args = {};
            String[] sig = {};
            getServer().invoke(getDeployer(),
                               "checkIncompleteDeployments", args, sig);
         }
         catch (Exception e)
         {
            Throwable t = JMXExceptionDecoder.decode(e);
            log.error(t);
         }
      }
   }

   protected boolean updateSorter()
   {
      // Check to see if mainDeployer suffix list has changed.
      if (sorter instanceof DefaultDeploymentSorter)
      {
         DefaultDeploymentSorter defaultSorter = (DefaultDeploymentSorter)sorter;
         if (defaultSorter.getSuffixOrder() != mainDeployer.getSuffixOrder())
         {
            defaultSorter.setSuffixOrder(mainDeployer.getSuffixOrder());
            return true;
         }
      }
      return false;
   }

   /////////////////////////////////////////////////////////////////////////
   //                     Service/ServiceMBeanSupport                     //
   /////////////////////////////////////////////////////////////////////////

   public ObjectName preRegister(MBeanServer server, ObjectName name)
      throws Exception
   {
      // get server's home for relative paths, need this for setting
      // attribute final values, so we need to do it here
      ServerConfig serverConfig = ServerConfigLocator.locate();
      serverHome = serverConfig.getServerHomeDir();
      serverHomeURL = serverConfig.getServerHomeURL();

      return super.preRegister(server, name);
   }

   protected void createService() throws Exception
   {
      // Perform a couple of sanity checks
      if (this.filter == null)
      {
         throw new IllegalStateException("'FilterInstance' attribute not configured");
      }
      if (this.sorter == null)
      {
         throw new IllegalStateException("'URLComparator' attribute not configured");
      }
      // ok, proceed with normal createService()
      super.createService();
   }
   
   /////////////////////////////////////////////////////////////////////////
   //                           DeployedURL                               //
   /////////////////////////////////////////////////////////////////////////

   /**
    * A container and help class for a deployed URL.
    * should be static at this point, with the explicit scanner ref, but I'm (David) lazy.
    */
   protected class DeployedURL
   {
      public URL url;
      /** The url to check to decide if we need to redeploy */
      public URL watchUrl;
      
      public long deployedLastModified;

      public DeployedURL(final URL url)
      {
         this.url = url;
      }

      public void deployed()
      {
         deployedLastModified = getLastModified();
      }
      public boolean isFile()
      {
         return url.getProtocol().equals("file");
      }

      public File getFile()
      {
         return new File(url.getFile());
      }

      public boolean isRemoved()
      {
         if (isFile())
         {
            File file = getFile();
            return !file.exists();
         }
         return false;
      }

      public long getLastModified()
      {
         if (watchUrl == null)
         {
            try
            {
               Object o = getServer().invoke(
                     getDeployer(),
                     "getWatchUrl",
                     new Object[] { url },
                     new String[] { URL.class.getName() }
                     );
               watchUrl = o == null ? url : (URL)o;
               getLog().debug("Watch URL for: " + url + " -> " + watchUrl);
            }
            catch (Exception e)
            {
               watchUrl = url;
               getLog().debug("Unable to obtain watchUrl from deployer. Use url: " + url, e);
            }
         }

         try
         {
            URLConnection connection;
            if (watchUrl != null)
            {
               connection = watchUrl.openConnection();
            } 
            else
            {
               connection = url.openConnection();
            }
            // no need to do special checks for files...
            // org.jboss.net.protocol.file.FileURLConnection correctly
            // implements the getLastModified method.
            long lastModified = connection.getLastModified();

            return lastModified;
         }
         catch (java.io.IOException e)
         {
            log.warn("Failed to check modification of deployed url: " + url, e);
         }
         return -1;
      }

      public boolean isModified()
      {
         long lastModified = getLastModified();
         if (lastModified == -1)
         {
            // ignore errors fetching the timestamp - see bug 598335
            return false;
         }
         return deployedLastModified != lastModified;
      }

      public int hashCode()
      {
         return url.hashCode();
      }

      public boolean equals(final Object other)
      {
         if (other instanceof DeployedURL)
         {
            return ((DeployedURL)other).url.equals(this.url);
         }
         return false;
      }

      public String toString()
      {
         return super.toString() +
         "{ url=" + url +
         ", deployedLastModified=" + deployedLastModified +
         " }";
      }
   }
}
