/**
 * JOnAS: Java(TM) Open Application Server
 * Copyright (C) 2007-2009 Bull S.A.S.
 * Contact: jonas-team@ow2.org
 *
 * This library 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 library 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 library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 *
 * --------------------------------------------------------------------------
 * $Id: DeployableMonitor.java 20331 2010-09-16 15:28:56Z pelletib $
 * --------------------------------------------------------------------------
 */

package org.ow2.jonas.deployablemonitor;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import java.util.concurrent.ConcurrentHashMap;

import org.ow2.jonas.datasource.deploy.deployable.api.DatasourceDeployable;
import org.ow2.util.archive.api.IArchive;
import org.ow2.util.archive.impl.ArchiveManager;
import org.ow2.util.ee.deploy.api.deployable.EARDeployable;
import org.ow2.util.ee.deploy.api.deployable.EJBDeployable;
import org.ow2.util.ee.deploy.api.deployable.IDeployable;
import org.ow2.util.ee.deploy.api.deployable.OSGiDeployable;
import org.ow2.util.ee.deploy.api.deployable.RARDeployable;
import org.ow2.util.ee.deploy.api.deployable.WARDeployable;
import org.ow2.util.ee.deploy.api.deployer.DeployerException;
import org.ow2.util.ee.deploy.api.deployer.IDeployerManager;
import org.ow2.util.ee.deploy.api.deployer.UnsupportedDeployerException;
import org.ow2.util.ee.deploy.impl.helper.DeployableHelper;
import org.ow2.util.ee.deploy.impl.helper.DeployableHelperException;
import org.ow2.util.log.Log;
import org.ow2.util.log.LogFactory;
import org.ow2.util.plan.deploy.deployable.api.DeploymentPlanDeployable;
import org.ow2.util.plan.deploy.deployable.api.RepositoryDeployable;

/**
 * This monitor will search all the deployable from a list of directories.<br>
 * In development mode, this monitor will detect changes on the deployables and
 * then restart or undeploy them if they have been changed or removed.
 * @author Florent BENOIT
 */
public class DeployableMonitor extends Thread {

    /**
     * Logger.
     */
    private static Log logger = LogFactory.getLog(DeployableMonitor.class);

    /**
     * List of directories to analyze.
     */
    private List<File> directories = null;

    /**
     * Development mode or not ?
     */
    private boolean developmentMode = false;

    /**
     * Monitor interval between each directory scan.
     */
    private int monitorInterval;

    /**
     * Stop order received ?
     */
    private boolean stopped = false;

    /**
     * Map between a File (monitored) and the last updated file.
     */
    private Map<File, Long> modifiedFiles = null;

    /**
     * List of deployed files (by this monitor).
     */
    private Map<File, IDeployable<?>> deployed = null;

    /**
     * Map between a File and its last length known.
     */
    private Map<File, Long> fileLengths = null;

    /**
     * List of File that have a failed deployment (by this monitor).
     */
    private List<File> failed = null;

    /**
     * Deployer Manager. (that allow us to deploy Deployable).
     */
    private IDeployerManager deployerManager = null;

    /**
     * First launch of this monitor.
     */
    private boolean firstCheck = true;

    /**
     * Exclusion filter.
     */
    private ExclusionFilenameFilter filter = null;

    /**
     * Allow to inform the Service that the firstCheck ended.
     */
    private DeployableMonitorService myService = null;

    /**
     * Constructor of this monitor.
     * @param service the {@link DeployableMonitorService} instance
     */
    public DeployableMonitor(final DeployableMonitorService service) {
        this.directories = new LinkedList<File>();
        this.modifiedFiles = new WeakHashMap<File, Long>();
        this.deployed = new ConcurrentHashMap<File, IDeployable<?>>();
        this.fileLengths = new HashMap<File, Long>();
        this.failed = new ArrayList<File>();
        this.filter  = new ExclusionFilenameFilter();
        this.myService = service;
    }

    /**
     * reset the state of all deployables.
     */
    public void reset() {
        if (firstCheck) {
            return;
        }

        this.modifiedFiles.clear();
        this.deployed.clear();
        this.fileLengths.clear();

        for (File deployDirectory : directories) {
            File[] files = deployDirectory.listFiles(filter);

            if (files == null) {
                continue;
            }

            for (File file : files) {
                try {
                    IArchive archive = ArchiveManager.getInstance().getArchive(file);
                    if (archive == null) {
                        continue;
                    }
                    IDeployable<?> deployable = DeployableHelper.getDeployable(archive);
                    if (deployerManager.isDeployed(deployable)) {

                        deployed.put(file, deployable);
                        fileLengths.put(file, getFileSize(file));
                        // hasBeenUpdated will update the modifiedFiles
                        hasBeenUpdated(file);
                    }
                } catch (Throwable t) {
                    logger.warn("Cannot reset file {0}", file, t);
                }
            }
        }
    }

    /**
     * Start the thread of this class. <br>
     * It will search and deploy files to deploy.<br>
     * In development mode, it will check the changes.
     */
    @Override
    public void run() {
        for (;;) {
            if (stopped) {
                // Stop the thread
                return;
            }

            if (developmentMode || firstCheck) {
                // Save file lengths in order to avoid deploying file for which copy is in process
                saveFileLengths();

                // 20% of the monitor interval is spent to wait in order to guarantee the file integrity
                // If the file is being copied, the file size increases in the time
                try {
                    Thread.sleep((long) (monitorInterval * 0.2));
                } catch (InterruptedException e) {
                    throw new RuntimeException("Thread fail to sleep");
                }

                // Undeploy/ReDeploy archives for deployed modules
                updateArchives();

                // Check new archives/containers to start
                checkNewArchives();

                if (firstCheck) {
                    myService.firstCheckEnded();
                }

                // Next step, we will be in a checking step.
                firstCheck = false;
            }

            try {
                Thread.sleep((long) (monitorInterval * 0.8));
            } catch (InterruptedException e) {
                throw new RuntimeException("Thread fail to sleep");
            }
        }
    }

    /**
     * Save file lengths in scanned directories.
     */
    private void saveFileLengths() {
        // Clear Map
        fileLengths.clear();

        for (File deployDirectory : directories) {
            // Get files
            File[] files = deployDirectory.listFiles(filter);

            // Next directory if there are no files to scan.
            if (files == null) {
                continue;
            }

            for (File file : files) {
                try {
                    fileLengths.put(file, getFileSize(file));
                } catch (Exception e) {
                    // File cannot be read
                    // or a sub file is not completed (in case of a directory)
                }
            }
        }
    }

    /**
     * The windows related method test if the file is complete (any write
     * operations on the given file are finished).
     * @param file tested file (regular file only)
     * @throws IOException if the given file is not completed (only thrown
     *                     under windows when file is not completed)
     */
    private static void checkFileCompleteness(final File file) throws IOException {
        if (file.isFile()) {
            // Try to open an input stream on the file
            // This should throw an exception if the file copy is not finished (Windows only)
            FileInputStream fis = new FileInputStream(file);
            fis.close();
        }
    }

    /**
     * Return the file or directory size
     * @param file a file or directory
     * @return the total file size (files in folders included)
     * @throws Exception if one of the analyzed files was not completed
     */
    private static long getFileSize(final File file) throws Exception {
        if (file.isFile()) {
            checkFileCompleteness(file);
            return file.length();
        }

        // Directory case
        long size = 0;
        File[] childs = file.listFiles();
        for (File child : childs) {
            // Add the size of the child
            size += getFileSize(child);
        }

        return size;

    }

    /**
     * Check new archives/containers.
     */
    private void checkNewArchives() {
        try {
            detectNewArchives();
        } catch (Exception e) { // Catch all exception
            logger.error("Problem when trying to find and deploy new archives", e);
        } catch (Error e) {
            logger.error("Error when trying to find and deploy new archives", e);
        }
    }

    /**
     * Undeploy/ReDeploy archives for deployed modules.
     */
    private void updateArchives() {
        try {
            checkModifiedDeployables();
        } catch (Exception e) { // Catch all exception
            logger.error("Problem when checking current deployables", e);
        } catch (Error e) {
            logger.error("Error when checking current deployables", e);
        }
    }

    /**
     * Scan all files present in the deploy directory and deploy them. (if not
     * deployed).
     * @throws DeployableMonitorException if there is a problem during the scan
     */
    private void detectNewArchives() throws DeployableMonitorException {

        for (File deployDirectory : directories) {
            // get files
            File[] files = deployDirectory.listFiles(filter);

            // next directory if there are no files to scan.
            if (files == null) {
                continue;
            }

            // Sort the files by names
            Arrays.sort(files, new LexicographicallyFileComparator());

            // Sort the file by type (first check only)
            if (firstCheck) {
                // Build a list of deployable
                List<File> jdbcDsDeployables = new LinkedList<File>();
                List<File> osgiDeployables = new LinkedList<File>();
                List<File> rarDeployables = new LinkedList<File>();
                List<File> ejbDeployables = new LinkedList<File>();
                List<File> warDeployables = new LinkedList<File>();
                List<File> earDeployables = new LinkedList<File>();
                List<File> deploymentplanDeployables = new LinkedList<File>();
                List<File> repositoryDeployables = new LinkedList<File>();
                List<File> unknownDeployables = new LinkedList<File>();
                for (File f : files) {
                    // Get deployable
                    IArchive archive = ArchiveManager.getInstance().getArchive(f);
                    if (archive == null) {
                        logger.warn("Ignoring invalid file ''{0}''", f);
                        continue;
                    }

                    IDeployable<?> deployable;
                    try {
                        deployable = DeployableHelper.getDeployable(archive);
                    } catch (DeployableHelperException e) {
                        // Deployment of this deployable has failed
                        failed.add(f);
                        throw new DeployableMonitorException("Cannot get a deployable for the archive '" + archive + "'", e);
                    }

                    if (DatasourceDeployable.class.isInstance(deployable)) {
                        jdbcDsDeployables.add(f);
                    } else if (OSGiDeployable.class.isInstance(deployable)) {
                        osgiDeployables.add(f);
                    } else if (RARDeployable.class.isInstance(deployable)) {
                        rarDeployables.add(f);
                    } else if (EJBDeployable.class.isInstance(deployable)) {
                        ejbDeployables.add(f);
                    } else if (WARDeployable.class.isInstance(deployable)) {
                        warDeployables.add(f);
                    } else if (EARDeployable.class.isInstance(deployable)) {
                        earDeployables.add(f);
                    } else if (DeploymentPlanDeployable.class.isInstance(deployable)) {
                        deploymentplanDeployables.add(f);
                    } else if (RepositoryDeployable.class.isInstance(deployable)) {
                        repositoryDeployables.add(f);
                    } else {
                        logger.debug("Unknown type of deployable: {0}", deployable);
                        unknownDeployables.add(f);
                    }


                }

                // Create new list (type and lexico order)
                List<File> newList = new LinkedList<File>();

                // First use the repositories
                newList.addAll(repositoryDeployables);
                // then JDBC DS
                newList.addAll(jdbcDsDeployables);
                // then the OSGi bundles
                newList.addAll(osgiDeployables);
                // then RARs
                newList.addAll(rarDeployables);
                // then the deployment plans (modules in deployment plan may use connector resources like JDBC, JORAM, etc)
                newList.addAll(deploymentplanDeployables);
                // then EJBs
                newList.addAll(ejbDeployables);
                // and then WARs
                newList.addAll(warDeployables);
                // and then EARs
                newList.addAll(earDeployables);
                // other files
                newList.addAll(unknownDeployables);


                if (newList.size() > 0) {
                    logger.info("Deployables to deploy at startup: [{0}]", newList);
                }

                // Update the array
                files = newList.toArray(new File[newList.size()]);

            }

            // analyze each file to detect new modules that are not yet deployed.
            for (File file : files) {
                // Already deployed ?
                if (deployed.containsKey(file)) {
                    // yes, then check other files
                    continue;
                }

                // File length has changed: file copy is not finished ?
                if (fileLengthHasChanged(file)) {
                    continue;
                }

                // This module has failed previously ?
                if (failed.contains(file)) {
                    // If the module hasn't been updated, no need to deploy it again as it will fails again
                    if (!hasBeenUpdated(file)) {
                        continue;
                    }
                    // Cleanup the previous failure and try again the deployment
                    failed.remove(file);
                }

                // Else, get the deployable
                IArchive archive = ArchiveManager.getInstance().getArchive(file);
                if (archive == null) {
                    logger.warn("Ignoring invalid file ''{0}''", file);
                    continue;
                }
                IDeployable<?> deployable;
                try {
                    deployable = DeployableHelper.getDeployable(archive);
                } catch (DeployableHelperException e) {
                    // Deployment of this deployable has failed
                    failed.add(file);
                    throw new DeployableMonitorException("Cannot get a deployable for the archive '" + archive + "'", e);
                }

                // Already deployed ? (don't check DeploymentPlanDeployable)
                try {
                    if (deployerManager.isDeployed(deployable) && !DeploymentPlanDeployable.class.isInstance(deployable)) {
                        deployed.put(file, deployable);
                        continue;
                    }
                } catch (Exception e) {
                    // Deployable not supported.
                }


                if (stopped) {
                    // Don't deploy new archives after the reception of a stop order
                    return;
                }

                logger.debug("Detect a new Deployable ''{0}'' and deploying it.", deployable);

                // Now, deploy the file
                try {
                    deployerManager.deploy(deployable);
                    // Store the new deployable
                    deployed.put(file, deployable);
                    // Perform a garbage collector to avoid file lock
                    System.gc();
                } catch (UnsupportedDeployerException e) {
                    // Deployment of this deployable is not supported.
                    // We will try to deploy this file in the next detection cycle.
                    continue;
                } catch (DeployerException e) {
                    // Deployment of this deployable has failed
                    failed.add(file);
                    throw new DeployableMonitorException("Cannot deploy the deployable '" + deployable + "'", e);
                } catch (RuntimeException e) {
                    // Runtime exception but deployment has failed
                    failed.add(file);
                    throw new DeployableMonitorException("RuntimeException when deploying the deployable '"
                            + deployable + "'", e);
                } catch (Error e) {
                    // Error but deployment has failed
                    failed.add(file);
                    throw new DeployableMonitorException("Error when deploying the deployable '"
                            + deployable + "'", e);
                }
            }
        }
    }

    /**
     * Gets the last modified attribute of a given archive.<br> If it is a
     * directory, returns the last modified file of the archive.
     * @param archive the archive to monitor.
     * @return the last modified version of the given archive.
     */
    protected long getLastModified(final File archive) {
        if (archive.isFile()) {
            return archive.lastModified();
        }
        // else
        File[] files = archive.listFiles();
        long last = 0;
        if (files != null) {
            for (File f : files) {
                last = Math.max(last, getLastModified(f));
            }
        }
        return last;
    }

    /**
     * Check if the given file has been updated since the last check.
     * @param file the file to test
     * @return true if the archive has been updated
     */
    protected boolean hasBeenUpdated(final File file) {
        // File length has changed: file copy is not finished ?
        if (fileLengthHasChanged(file)) {
            return false;
        }

        // get lastmodified for this URL
        long previousLastModified = 0;
        Long l = modifiedFiles.get(file);
        if (l != null) {
            previousLastModified = l.longValue();
        }

        long updatedModified = getLastModified(file);

        // first check. nothing to do
        if (previousLastModified == 0) {
            // Store initial time
            modifiedFiles.put(file, Long.valueOf(updatedModified));
            return false;
        }
        // URL has been updated since the last time
        if (updatedModified > previousLastModified) {
            modifiedFiles.put(file, Long.valueOf(updatedModified));
            return true;
        }

        return false;
    }

    /**
     * Check if the current deployables that are deployed have been updated.
     * If it is the case, undeploy them and then deploy it again (except for EJB3 Deployable where there is a stop/start).
     * @throws DeployableMonitorException if the redeployment fails
     */
    private void checkModifiedDeployables() throws DeployableMonitorException {
        // Get list of files that are failed
        Iterator<File> fileIterator = failed.iterator();

        // For each failed module, check if the module has been removed
        while (fileIterator.hasNext()) {
            File f = fileIterator.next();
            // File has been removed
            if (!f.exists()) {
                fileIterator.remove();
            }
        }

        // Get list of files that are undeployed
        Set<File> files = deployed.keySet();
        for (File f : files) {
            IDeployable<?> deployable = deployed.get(f);
            try {
                if (!deployerManager.isDeployed(deployable)) {
                    deployed.remove(f);
                }
            } catch (Exception e) {
                deployed.remove(f);
            }
        }

        // Nothing to do if no modules are deployed.
        if (files == null) {
            return;
        }

        // For each deployed module that is not an EJB3, check if the module has been updated
        for (File f : files) {
            IDeployable<?> deployable = deployed.get(f);

            // Not yet deployed ?
            if (deployable == null) {
                continue;
            }

            // File has been removed
            if (!f.exists()) {
                // undeploy
                logger.info("Deployable ''{0}'' has been removed on the filesystem, undeploy it", deployable);
                try {
                    deployerManager.undeploy(deployable);
                    // Perform a garbage collector to avoid file lock during redeployment
                    System.gc();
                } catch (UnsupportedDeployerException e) {
                    // Undeployment of this deployable is not supported.
                    // We will try to undeploy this file in the next detection cycle.
                    continue;
                } catch (DeployerException e) {
                    logger.error("Undeploy of the deployable '" + deployable + "' has failed", e);
                    failed.add(f);
                } catch (RuntimeException e) {
                    logger.error("Undeploy of the deployable '" + deployable + "' has failed", e);
                    failed.add(f);
                } finally {
                    // even in error case, the file should have been removed
                    deployed.remove(f);
                }
                continue;
            }

            // Update has been detected, need to undeploy and then to deploy again
            if (hasBeenUpdated(f)) {
                logger.info("Deployable ''{0}'' has been updated, reloading it", deployable);
                try {
                    deployerManager.undeploy(deployable);
                    deployed.remove(f);
                    // Perform a garbage collector to avoid file lock during redeployment
                    System.gc();
                } catch (UnsupportedDeployerException e) {
                    // Undeployment of this deployable is not supported.
                    // We will try to undeploy this file in the next detection cycle.
                    continue;
                } catch (DeployerException e) {
                    logger.error("Undeploy of the deployable '" + deployable + "' has failed", e);
                    // Deployment has failed, it is now undeployed
                    deployed.remove(f);
                    failed.add(f);
                }

                // Get a new deployable
                IArchive archive = ArchiveManager.getInstance().getArchive(f);
                if (archive == null) {
                    logger.warn("Ignoring invalid file ''{0}''", f);
                    continue;
                }
                IDeployable<?> newDeployable;
                try {
                    newDeployable = DeployableHelper.getDeployable(archive);
                } catch (DeployableHelperException e) {
                    logger.error("Cannot get a deployable for the archive '" + archive + "'", e);
                    continue;
                }
                try {
                    deployerManager.deploy(newDeployable);
                    // Store the new deployable
                    deployed.put(f, newDeployable);
                    // Perform a garbage collector to avoid file lock
                    System.gc();
                } catch (UnsupportedDeployerException e) {
                    // Deployment of this deployable is not supported.
                    // We will try to deploy this file in the next detection cycle.
                    continue;
                } catch (DeployerException e) {
                    // Deployment of this deployable has failed
                    failed.add(f);
                    throw new DeployableMonitorException("Cannot redeploy the deployable '" + newDeployable + "'.", e);
                }
            }
        }
    }

    /**
     * Check if the file length has changed.
     * @param file The given file
     * @return True if the file length has changed
     */
    private boolean fileLengthHasChanged(final File file) {
        // File length not known
        if (!fileLengths.containsKey(file)) {
            return true;
        }

        long storedFileLength = fileLengths.get(file);
        long currentFileLength = 0;
        try {
            currentFileLength = getFileSize(file);
        } catch (Exception e) {
            // File cannot be checked, probably the file is being written.
            return true;
        }
        if (storedFileLength != currentFileLength) {
            // File length has changed
            return true;
        }

        // No change in file length
        return false;
    }

    /**
     * @return the list of directories that this monitor is monitoring.
     */
    public List<File> getDirectories() {
        return directories;
    }

    /**
     * Sets the list of directories to monitor.
     * @param directories the list of directories to use.
     */
    public void setDirectories(final List<File> directories) {
        this.directories = directories;
    }

    /**
     * Add a directory to the list of directories to monitor.
     * @param directories the list of directories to use.
     */
    public void addDirectory(final File directory) {
        directories.add(directory);
    }

    /**
     * Remove a directory to the list of directories to monitor.
     * @param directories the list of directories to use.
     */
    public void removeDirectory(final File directory) {
        directories.remove(directory);
    }

    /**
     * @return true if the development is enabled, else false (production mode).
     */
    public boolean isDevelopmentMode() {
        return developmentMode;
    }

    /**
     * Enable or disable the development mode.
     * @param developmentMode true if it has to be enabled.
     */
    public void setDevelopmentMode(final boolean developmentMode) {
        this.developmentMode = developmentMode;
    }

    /**
     * Set the monitor interval between each directory scan.
     * @param monitorInterval value to set
     */
    public void setMonitorInterval(final int monitorInterval) {
        this.monitorInterval = monitorInterval;
    }

    /**
     * Receives a stop order.
     */
    public void stopOrder() {
        this.stopped = true;
    }

    /**
     * @return the instance of the deployer manager.
     */
    public IDeployerManager getDeployerManager() {
        return deployerManager;
    }

    /**
     * Sets the deployer manager for deployers.
     * @param deployerManager the instance of the deployer manager.
     */
    public void setDeployerManager(final IDeployerManager deployerManager) {
        this.deployerManager = deployerManager;
    }

    /**
     * @param patterns List of exclusion patterns.
     */
    public void setExclusionPatterns(final List<String> patterns) {
        filter.setExclusionList(patterns);
    }
}
