/*
 * 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.catalina.ha.deploy;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

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

import org.apache.catalina.Container;
import org.apache.catalina.Context;
import org.apache.catalina.Engine;
import org.apache.catalina.Host;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.ha.ClusterDeployer;
import org.apache.catalina.ha.ClusterListener;
import org.apache.catalina.ha.ClusterMessage;
import org.apache.catalina.tribes.Member;
import org.apache.catalina.util.ContextName;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.modeler.Registry;
import org.apache.tomcat.util.res.StringManager;


/**
 * <p>
 * A farm war deployer is a class that is able to deploy/undeploy web applications in WAR from within the cluster.
 * </p>
 * Any host can act as the admin, and will have three directories
 * <ul>
 * <li>watchDir - the directory where we watch for changes</li>
 * <li>deployDir - the directory where we install applications</li>
 * <li>tempDir - a temporaryDirectory to store binary data when downloading a war from the cluster</li>
 * </ul>
 * Currently we only support deployment of WAR files since they are easier to send across the wire.
 */
public class FarmWarDeployer extends ClusterListener implements ClusterDeployer, FileChangeListener {
    /*--Static Variables----------------------------------------*/
    private static final Log log = LogFactory.getLog(FarmWarDeployer.class);
    private static final StringManager sm = StringManager.getManager(FarmWarDeployer.class);

    /*--Instance Variables--------------------------------------*/
    /**
     * Whether the deployer has been started.
     */
    protected boolean started = false;

    /**
     * Map of file name to factory for in-progress file transfers.
     */
    protected final Map<String,FileMessageFactory> fileFactories = new ConcurrentHashMap<>();

    /**
     * Deployment directory.
     */
    protected String deployDir;
    private File deployDirFile = null;

    /**
     * Temporary directory.
     */
    protected String tempDir;
    private File tempDirFile = null;

    /**
     * Watch directory.
     */
    protected String watchDir;
    private File watchDirFile = null;

    /**
     * Whether the watch directory feature is enabled.
     */
    protected boolean watchEnabled = false;

    /**
     * The watcher for monitoring the watch directory.
     */
    protected WarWatcher watcher = null;

    /**
     * Iteration count for background processing.
     */
    private int count = 0;

    /**
     * Frequency of the Farm watchDir check. Cluster wide deployment will be done once for the specified amount of
     * backgroundProcess calls (ie, the lower the amount, the most often the checks will occur).
     */
    protected int processDeployFrequency = 2;

    /**
     * Path where context descriptors should be deployed.
     */
    protected File configBase = null;

    /**
     * The associated host.
     */
    protected Host host = null;

    /**
     * MBean server.
     */
    protected MBeanServer mBeanServer = null;

    /**
     * The associated deployer ObjectName.
     */
    protected ObjectName oname = null;

    /**
     * The maximum valid time(in seconds) for FileMessageFactory.
     */
    protected int maxValidTime = 5 * 60;

    /*--Constructor---------------------------------------------*/
    /**
     * Creates a new FarmWarDeployer instance.
     */
    public FarmWarDeployer() {
    }

    /*--Logic---------------------------------------------------*/
    @Override
    public void start() throws Exception {
        if (started) {
            return;
        }
        Container hcontainer = getCluster().getContainer();
        if (!(hcontainer instanceof Host)) {
            log.error(sm.getString("farmWarDeployer.hostOnly"));
            return;
        }
        host = (Host) hcontainer;

        // Check to correct engine and host setup
        Container econtainer = host.getParent();
        if (!(econtainer instanceof Engine)) {
            log.error(sm.getString("farmWarDeployer.hostParentEngine", host.getName()));
            return;
        }
        Engine engine = (Engine) econtainer;
        String hostname = host.getName();
        try {
            oname = new ObjectName(engine.getName() + ":type=Deployer,host=" + hostname);
        } catch (Exception e) {
            log.error(sm.getString("farmWarDeployer.mbeanNameFail", engine.getName(), hostname), e);
            return;
        }
        if (watchEnabled) {
            watcher = new WarWatcher(this, getWatchDirFile());
            if (log.isInfoEnabled()) {
                log.info(sm.getString("farmWarDeployer.watchDir", getWatchDir()));
            }
        }

        configBase = host.getConfigBaseFile();

        // Retrieve the MBean server
        mBeanServer = Registry.getRegistry(null).getMBeanServer();

        started = true;
        count = 0;

        getCluster().addClusterListener(this);

        if (log.isInfoEnabled()) {
            log.info(sm.getString("farmWarDeployer.started"));
        }
    }

    /*
     * stop cluster wide deployments
     *
     * @see org.apache.catalina.ha.ClusterDeployer#stop()
     */
    @Override
    public void stop() throws LifecycleException {
        started = false;
        getCluster().removeClusterListener(this);
        count = 0;
        if (watcher != null) {
            watcher.clear();
            watcher = null;

        }
        if (log.isInfoEnabled()) {
            log.info(sm.getString("farmWarDeployer.stopped"));
        }
    }

    /**
     * Callback from the cluster, when a message is received, The cluster will broadcast it invoking the messageReceived
     * on the receiver.
     *
     * @param msg ClusterMessage - the message received from the cluster
     */
    @Override
    public void messageReceived(ClusterMessage msg) {
        try {
            if (msg instanceof FileMessage) {
                FileMessage fmsg = (FileMessage) msg;
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("farmWarDeployer.msgRxDeploy", fmsg.getContextName(), fmsg.getFileName()));
                }
                FileMessageFactory factory = getFactory(fmsg);
                // TODO correct second try after app is in service!
                if (factory.writeMessage(fmsg)) {
                    // last message received war file is completed
                    String name = factory.getFile().getName();
                    if (!name.endsWith(".war")) {
                        name = (new ContextName(name, true)).getBaseName() + ".war";
                    }
                    File deployable = new File(getDeployDirFile(), name);
                    try {
                        String contextName = fmsg.getContextName();
                        if (tryAddServiced(contextName)) {
                            try {
                                remove(contextName);
                                if (!factory.getFile().renameTo(deployable)) {
                                    log.error(
                                            sm.getString("farmWarDeployer.renameFail", factory.getFile(), deployable));
                                }
                            } finally {
                                removeServiced(contextName);
                            }
                            check(contextName);
                            if (log.isTraceEnabled()) {
                                log.trace(sm.getString("farmWarDeployer.deployEnd", contextName));
                            }
                        } else {
                            log.error(sm.getString("farmWarDeployer.servicingDeploy", contextName, name));
                        }
                    } catch (Exception e) {
                        log.error(sm.getString("farmWarDeployer.fileMessageError"), e);
                    } finally {
                        removeFactory(fmsg);
                    }
                }
            } else if (msg instanceof UndeployMessage) {
                try {
                    UndeployMessage umsg = (UndeployMessage) msg;
                    String contextName = umsg.getContextName();
                    if (log.isTraceEnabled()) {
                        log.trace(sm.getString("farmWarDeployer.msgRxUndeploy", contextName));
                    }
                    if (tryAddServiced(contextName)) {
                        try {
                            remove(contextName);
                        } finally {
                            removeServiced(contextName);
                        }
                        if (log.isTraceEnabled()) {
                            log.trace(sm.getString("farmWarDeployer.undeployEnd", contextName));
                        }
                    } else {
                        log.error(sm.getString("farmWarDeployer.servicingUndeploy", contextName));
                    }
                } catch (Exception e) {
                    log.error(sm.getString("farmWarDeployer.undeployMessageError"), e);
                }
            }
        } catch (IOException ioe) {
            log.error(sm.getString("farmWarDeployer.msgIoe"), ioe);
        }
    }

    /**
     * Create factory for all transported war files
     *
     * @param msg The file
     *
     * @return Factory for all app message (war files)
     *
     * @throws FileNotFoundException Missing file error
     * @throws IOException           Other IO error
     */
    public synchronized FileMessageFactory getFactory(FileMessage msg) throws FileNotFoundException, IOException {
        File writeToFile = new File(getTempDirFile(), msg.getFileName());
        FileMessageFactory factory = fileFactories.get(msg.getFileName());
        if (factory == null) {
            factory = FileMessageFactory.getInstance(writeToFile, true);
            factory.setMaxValidTime(maxValidTime);
            fileFactories.put(msg.getFileName(), factory);
        }
        return factory;
    }

    /**
     * Remove file (war) from messages
     *
     * @param msg The file
     */
    public void removeFactory(FileMessage msg) {
        fileFactories.remove(msg.getFileName());
    }

    /**
     * {@inheritDoc}
     * <p>
     * This listener accepts only FileMessage or UndeployMessage.
     */
    @Override
    public boolean accept(ClusterMessage msg) {
        return msg instanceof FileMessage || msg instanceof UndeployMessage;
    }

    /**
     * Install a new web application, whose web application archive is at the specified URL, into this container and all
     * the other members of the cluster with the specified context name.
     * <p>
     * If this application is successfully installed locally, a ContainerEvent of type <code>INSTALL_EVENT</code> will
     * be sent to all registered listeners, with the newly created <code>Context</code> as an argument.
     *
     * @param contextName The context name to which this application should be installed (must be unique)
     * @param webapp      A WAR file or unpacked directory structure containing the web application to be installed
     *
     * @exception IllegalArgumentException if the specified context name is malformed
     * @exception IllegalStateException    if the specified context name is already deployed
     * @exception IOException              if an input/output error was encountered during installation
     */
    @Override
    public void install(String contextName, File webapp) throws IOException {
        Member[] members = getCluster().getMembers();
        if (members.length == 0) {
            return;
        }

        Member localMember = getCluster().getLocalMember();
        FileMessageFactory factory = FileMessageFactory.getInstance(webapp, false);
        FileMessage msg = new FileMessage(localMember, webapp.getName(), contextName);
        if (log.isTraceEnabled()) {
            log.trace(sm.getString("farmWarDeployer.sendStart", contextName, webapp));
        }
        msg = factory.readMessage(msg);
        while (msg != null) {
            for (Member member : members) {
                if (log.isTraceEnabled()) {
                    log.trace(sm.getString("farmWarDeployer.sendFragment", contextName, webapp, member));
                }
                getCluster().send(msg, member);
            }
            msg = factory.readMessage(msg);
        }
        if (log.isTraceEnabled()) {
            log.trace(sm.getString("farmWarDeployer.sendEnd", contextName, webapp));
        }
    }

    /**
     * Remove an existing web application, attached to the specified context name. If this application is successfully
     * removed, a ContainerEvent of type <code>REMOVE_EVENT</code> will be sent to all registered listeners, with the
     * removed <code>Context</code> as an argument. Deletes the web application war file and/or directory if they exist
     * in the Host's appBase.
     *
     * @param contextName The context name of the application to be removed
     * @param undeploy    boolean flag to remove web application from server
     *
     * @exception IllegalArgumentException if the specified context name is malformed
     * @exception IllegalArgumentException if the specified context name does not identify a currently installed web
     *                                         application
     * @exception IOException              if an input/output error occurs during removal
     */
    @Override
    public void remove(String contextName, boolean undeploy) throws IOException {
        if (getCluster().getMembers().length > 0) {
            if (log.isInfoEnabled()) {
                log.info(sm.getString("farmWarDeployer.removeStart", contextName));
            }
            Member localMember = getCluster().getLocalMember();
            UndeployMessage msg = new UndeployMessage(localMember, System.currentTimeMillis(),
                    "Undeploy:" + contextName + ":" + System.currentTimeMillis(), contextName);
            if (log.isTraceEnabled()) {
                log.trace(sm.getString("farmWarDeployer.removeTxMsg", contextName));
            }
            getCluster().send(msg);
        }
        // remove locally
        if (undeploy) {
            try {
                if (tryAddServiced(contextName)) {
                    try {
                        remove(contextName);
                    } finally {
                        removeServiced(contextName);
                    }
                    check(contextName);
                } else {
                    log.error(sm.getString("farmWarDeployer.removeFailRemote", contextName));
                }

            } catch (Exception e) {
                log.error(sm.getString("farmWarDeployer.removeFailLocal", contextName), e);
            }
        }

    }

    @Override
    public void fileModified(File newWar) {
        try {
            ContextName cn = new ContextName(newWar.getName(), true);
            // Ensure deployed war uses lower case ".war" extension
            File deployWar = new File(getDeployDirFile(), cn.getBaseName() + ".war");
            if (deployWar.exists() && deployWar.lastModified() > newWar.lastModified()) {
                if (log.isInfoEnabled()) {
                    log.info(sm.getString("farmWarDeployer.alreadyDeployed", cn.getName()));
                }
                return;
            }
            if (log.isInfoEnabled()) {
                log.info(sm.getString("farmWarDeployer.modInstall", cn.getName(), deployWar.getAbsolutePath()));
            }
            // install local
            if (tryAddServiced(cn.getName())) {
                try {
                    copy(newWar, deployWar);
                } finally {
                    removeServiced(cn.getName());
                }
                check(cn.getName());
                install(cn.getName(), deployWar);
            } else {
                log.error(sm.getString("farmWarDeployer.servicingDeploy", cn.getName(), deployWar.getName()));
            }
        } catch (Exception e) {
            log.error(sm.getString("farmWarDeployer.modInstallFail"), e);
        }
    }

    @Override
    public void fileRemoved(File removeWar) {
        try {
            ContextName cn = new ContextName(removeWar.getName(), true);
            if (log.isInfoEnabled()) {
                log.info(sm.getString("farmWarDeployer.removeLocal", cn.getName()));
            }
            remove(cn.getName(), true);
        } catch (Exception e) {
            log.error(sm.getString("farmWarDeployer.removeLocalFail"), e);
        }
    }

    /**
     * Invoke the remove method on the deployer.
     *
     * @param contextName The context to remove
     *
     * @throws Exception If an error occurs removing the context
     */
    protected void remove(String contextName) throws Exception {
        // TODO Handle remove also work dir content !
        // Stop the context first to be nicer
        Context context = (Context) host.findChild(contextName);
        if (context != null) {
            if (log.isDebugEnabled()) {
                log.debug(sm.getString("farmWarDeployer.undeployLocal", contextName));
            }
            context.stop();
            String baseName = context.getBaseName();
            File war = new File(host.getAppBaseFile(), baseName + ".war");
            File dir = new File(host.getAppBaseFile(), baseName);
            File xml = new File(configBase, baseName + ".xml");
            if (war.exists()) {
                if (!war.delete()) {
                    log.error(sm.getString("farmWarDeployer.deleteFail", war));
                }
            } else if (dir.exists()) {
                undeployDir(dir);
            } else {
                if (!xml.delete()) {
                    log.error(sm.getString("farmWarDeployer.deleteFail", xml));
                }
            }
        }
    }

    /**
     * Delete the specified directory, including all of its contents and subdirectories recursively.
     *
     * @param dir File object representing the directory to be deleted
     */
    protected void undeployDir(File dir) {

        String[] files = dir.list();
        if (files == null) {
            files = new String[0];
        }
        for (String s : files) {
            File file = new File(dir, s);
            if (file.isDirectory()) {
                undeployDir(file);
            } else {
                if (!file.delete()) {
                    log.error(sm.getString("farmWarDeployer.deleteFail", file));
                }
            }
        }
        if (!dir.delete()) {
            log.error(sm.getString("farmWarDeployer.deleteFail", dir));
        }
    }

    /**
     * Call watcher to check for deploy changes
     *
     * @see org.apache.catalina.ha.ClusterDeployer#backgroundProcess()
     */
    @Override
    public void backgroundProcess() {
        if (started) {
            if (watchEnabled) {
                count = (count + 1) % processDeployFrequency;
                if (count == 0) {
                    watcher.check();
                }
            }
            removeInvalidFileFactories();
        }

    }

    /*--Deployer Operations ------------------------------------*/

    /**
     * Check a context for deployment operations.
     *
     * @param name The context name
     *
     * @throws Exception Error invoking the deployer
     */
    protected void check(String name) throws Exception {
        String[] params = { name };
        String[] signature = { "java.lang.String" };
        mBeanServer.invoke(oname, "check", params, signature);
    }

    /**
     * Attempt to mark a context as being serviced
     *
     * @param name The context name
     *
     * @return {@code true} if the application was marked as being serviced and {@code false} if the application was
     *             already marked as being serviced
     *
     * @throws Exception Error invoking the deployer
     */
    protected boolean tryAddServiced(String name) throws Exception {
        String[] params = { name };
        String[] signature = { "java.lang.String" };
        Boolean result = (Boolean) mBeanServer.invoke(oname, "tryAddServiced", params, signature);
        return result.booleanValue();
    }

    /**
     * Mark a context as no longer being serviced.
     *
     * @param name The context name
     *
     * @throws Exception Error invoking the deployer
     */
    protected void removeServiced(String name) throws Exception {
        String[] params = { name };
        String[] signature = { "java.lang.String" };
        mBeanServer.invoke(oname, "removeServiced", params, signature);
    }

    /*--Instance Getters/Setters--------------------------------*/
    /**
     * Returns the deployment directory path.
     *
     * @return the deployment directory path
     */
    public String getDeployDir() {
        return deployDir;
    }

    /**
     * Returns the deployment directory as a File object.
     *
     * @return the deployment directory file
     */
    public File getDeployDirFile() {
        if (deployDirFile != null) {
            return deployDirFile;
        }

        File dir = getAbsolutePath(getDeployDir());
        this.deployDirFile = dir;
        return dir;
    }

    /**
     * Sets the deployment directory path.
     *
     * @param deployDir the deployment directory path
     */
    public void setDeployDir(String deployDir) {
        this.deployDir = deployDir;
        deployDirFile = null;
    }

    /**
     * Returns the temporary directory path.
     *
     * @return the temporary directory path
     */
    public String getTempDir() {
        return tempDir;
    }

    /**
     * Returns the temporary directory as a File object.
     *
     * @return the temporary directory file
     */
    public File getTempDirFile() {
        if (tempDirFile != null) {
            return tempDirFile;
        }

        File dir = getAbsolutePath(getTempDir());
        this.tempDirFile = dir;
        return dir;
    }

    /**
     * Sets the temporary directory path.
     *
     * @param tempDir the temporary directory path
     */
    public void setTempDir(String tempDir) {
        this.tempDir = tempDir;
        tempDirFile = null;
    }

    /**
     * Returns the watch directory path.
     *
     * @return the watch directory path
     */
    public String getWatchDir() {
        return watchDir;
    }

    /**
     * Returns the watch directory as a File object.
     *
     * @return the watch directory file
     */
    public File getWatchDirFile() {
        if (watchDirFile != null) {
            return watchDirFile;
        }

        File dir = getAbsolutePath(getWatchDir());
        this.watchDirFile = dir;
        return dir;
    }

    /**
     * Sets the watch directory path.
     *
     * @param watchDir the watch directory path
     */
    public void setWatchDir(String watchDir) {
        this.watchDir = watchDir;
        watchDirFile = null;
    }

    /**
     * Returns whether the watch directory feature is enabled.
     *
     * @return {@code true} if watch is enabled
     */
    public boolean isWatchEnabled() {
        return watchEnabled;
    }

    /**
     * Returns whether the watch directory feature is enabled.
     *
     * @return {@code true} if watch is enabled
     */
    public boolean getWatchEnabled() {
        return watchEnabled;
    }

    /**
     * Sets whether the watch directory feature is enabled.
     *
     * @param watchEnabled {@code true} to enable watch
     */
    public void setWatchEnabled(boolean watchEnabled) {
        this.watchEnabled = watchEnabled;
    }

    /**
     * Returns the frequency of watcher checks.
     *
     * @return the frequency of watcher checks
     */
    public int getProcessDeployFrequency() {
        return this.processDeployFrequency;
    }

    /**
     * Set the watcher checks frequency.
     *
     * @param processExpiresFrequency the new manager checks frequency
     */
    public void setProcessDeployFrequency(int processExpiresFrequency) {

        if (processExpiresFrequency <= 0) {
            return;
        }
        this.processDeployFrequency = processExpiresFrequency;
    }

    /**
     * Returns the maximum valid time for FileMessageFactory.
     *
     * @return the maximum valid time in seconds
     */
    public int getMaxValidTime() {
        return maxValidTime;
    }

    /**
     * Sets the maximum valid time for FileMessageFactory.
     *
     * @param maxValidTime the maximum valid time in seconds
     */
    public void setMaxValidTime(int maxValidTime) {
        this.maxValidTime = maxValidTime;
    }

    /**
     * Copy a file to the specified temp directory.
     *
     * @param from copy from temp
     * @param to   to host appBase directory
     *
     * @return true, copy successful
     */
    protected boolean copy(File from, File to) {
        try {
            if (from.getCanonicalPath().equals(to.getCanonicalPath())) {
                throw new IOException(sm.getString("farmWarDeployer.samePathCopy"));
            }

            if (!to.exists()) {
                if (!to.createNewFile()) {
                    log.error(sm.getString("fileNewFail", to));
                    return false;
                }
            }
        } catch (IOException ioe) {
            log.error(sm.getString("farmWarDeployer.fileCopyFail", from, to), ioe);
            return false;
        }

        try (java.io.FileInputStream is = new java.io.FileInputStream(from);
                java.io.FileOutputStream os = new java.io.FileOutputStream(to, false)) {
            byte[] buf = new byte[4096];
            while (true) {
                int len = is.read(buf);
                if (len < 0) {
                    break;
                }
                os.write(buf, 0, len);
            }
        } catch (IOException ioe) {
            log.error(sm.getString("farmWarDeployer.fileCopyFail", from, to), ioe);
            return false;
        }
        return true;
    }

    /**
     * Removes invalid file factories from the factory map.
     */
    protected void removeInvalidFileFactories() {
        String[] fileNames = fileFactories.keySet().toArray(new String[0]);
        for (String fileName : fileNames) {
            FileMessageFactory factory = fileFactories.get(fileName);
            if (!factory.isValid()) {
                fileFactories.remove(fileName);
            }
        }
    }

    private File getAbsolutePath(String path) {
        File dir = new File(path);
        if (!dir.isAbsolute()) {
            dir = new File(getCluster().getContainer().getCatalinaBase(), dir.getPath());
        }
        try {
            dir = dir.getCanonicalFile();
        } catch (IOException ignore) {
            // Ignore
        }
        return dir;
    }
}
