/*
 * RHQ Management Platform
 * Copyright (C) 2005-2008 Red Hat, Inc.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation version 2 of the License.
 *
 * This program 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */
package org.rhq.enterprise.server.agent;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.InetAddress;
import java.net.URL;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Map;
import java.util.Properties;
import java.util.prefs.BackingStoreException;
import java.util.prefs.InvalidPreferencesFormatException;
import java.util.prefs.Preferences;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.jboss.util.StringPropertyReplacer;

import org.rhq.core.util.stream.StreamUtil;
import org.rhq.enterprise.agent.AgentConfiguration;
import org.rhq.enterprise.agent.AgentConfigurationConstants;
import org.rhq.enterprise.agent.AgentConfigurationUpgrade;

/**
 * This is an MBean service that can be used to bootstrap a RHQ Agent that is embedded in a RHQ Server. This will create
 * a standalone classloader that will completely isolate the agent from any RHQ Server classloader (other than the
 * top-level system classloader). Note that this service MBean interface does not expose any agent-specific classes to
 * clients and it does not have direct access to any class outside of a select few classes found in the agent jar that
 * is in this service's classpath.
 *
 * <p>Note that because this service is deployed with the agent jars in it, you must deploy it in exploded form.</p>
 *
 * @author John Mazzitelli
 */
public class EmbeddedAgentBootstrapService implements EmbeddedAgentBootstrapServiceMBean {
    private Log log = LogFactory.getLog(EmbeddedAgentBootstrapService.class);

    /**
     * The agent itself.
     */
    private Object agent = null;

    /**
     * The location of the configuration file - can be a file path or path within classloader.
     */
    private String configFile = "META-INF/embedded-agent-configuration.xml";

    /**
     * The preferences node name that identifies the configuration set used to configure the services.
     */
    private String preferencesNodeName;

    /**
     * Properties that will be used to override preferences found in the preferences node and the configuration
     * preferences file.
     */
    private Properties configurationOverrides = null;

    /**
     * Arguments passed to the agent's main method.
     */
    private String[] arguments = null;

    /**
     * The location of the agent's resources, like jar files (jar files are located under the lib subdirectory).
     */
    private File embeddedAgentDirectory = null;

    /**
     * If <code>true</code>, this service will be told to start the agent immediately at startup by the RHQ Server.
     */
    private Boolean enabled = Boolean.FALSE;

    /**
     * If <code>true</code>, this bootstrap service will revert the agent's configuration back to the original
     * configuration file. Otherwise, the configuration will be that which is currently persisted in the preferences
     * store.
     */
    private Boolean resetConfigurationAtStartup = Boolean.FALSE;

    /**
     * This starts the agent in a completely isolated classloader in a completely isolated thread.
     *
     * @see EmbeddedAgentBootstrapServiceMBean#startAgent()
     */
    public void startAgent() throws Exception {
        //-- if this method signature changes, you must also change StartupServlet.startEmbeddedAgent

        if (!enabled.booleanValue()) {
            log.info("Will not start the embedded RHQ agent - it is disabled");
            return;
        }

        if ((agent == null) && (embeddedAgentDirectory != null)) {
            log.info("Starting the embedded RHQ Agent...");

            // we need to store the preferences prior to starting the agent
            if (resetConfigurationAtStartup.booleanValue()) {
                log.debug("Resetting the embedded agent's configuration back to its original factory settings");
                reloadAgentConfiguration();
                cleanDataDirectory();
            } else {
                log.debug("Loading the embedded agent's pre-existing configuration from preferences");
                prepareConfigurationPreferences();
            }

            // We need to build the agent's classloader so it is completely isolated.
            final URL[] jars = getEmbeddedAgentClasspath();
            final URL[] nativeDirs = getEmbeddedAgentNativeLibraryDirectories();
            final ClassLoader agentClassLoader = new EmbeddedAgentClassLoader(jars, nativeDirs);

            // we need to instantiate and start the agent in its own thread so it can have
            // a default context classloader that is our isolated classloader
            final Exception[] error = new Exception[1];
            final Object[] agentObject = new Object[1];

            Runnable agentRunnable = new Runnable() {
                public void run() {
                    try {
                        Class<?> agentClass = agentClassLoader.loadClass("org.rhq.enterprise.agent.AgentMain");
                        Constructor<?> agentConstructor = agentClass.getConstructor(new Class[] { String[].class });
                        Object agentInstance = agentConstructor.newInstance(new Object[] { getAgentArguments() });

                        agentClass.getMethod("start", new Class[0]).invoke(agentInstance, new Object[0]);

                        agentObject[0] = agentInstance;
                        error[0] = null;
                    } catch (Throwable t) {
                        agentObject[0] = null;
                        error[0] = new Exception("Cannot start the embedded agent. Cause: " + t, t);
                    }
                }
            };

            // create our thread that starts the agent with the isolated class loader as its context
            Thread agentThread = new Thread(agentRunnable, "Embedded RHQ Agent Main");
            agentThread.setDaemon(true);
            agentThread.setContextClassLoader(agentClassLoader);
            agentThread.start();
            agentThread.join();

            // at this point in time, the embedded agent bootstrap thread has finished and
            // the agent should have been started
            if (error[0] != null) {
                log.error("Failed to start the embedded RHQ Agent. Cause: " + error[0]);
                throw error[0];
            }

            log.info("Embedded RHQ Agent has been started!");
            this.agent = agentObject[0];
        }

        return;
    }

    public void stopAgent() throws Exception {
        if (agent != null) {
            log.info("Stopping the embedded RHQ Agent...");

            // all this funky threading/reflection is so we execute the command
            // in the isoloated context of the embedded agent
            final Exception[] error = new Exception[] { null };
            final Runnable agentRunnable = new Runnable() {
                public void run() {
                    try {
                        agent.getClass().getMethod("shutdown", new Class[0]).invoke(agent, new Object[0]);
                    } catch (Throwable t) {
                        error[0] = new Exception("Failed to stop the embedded RHQ Agent. Cause: " + t);
                    }
                }
            };

            // create our thread that executes the agent command with the isolated class loader as its context
            Thread agentThread = new Thread(agentRunnable, "Embedded RHQ Agent Shutdown Request");
            agentThread.setDaemon(true);
            agentThread.setContextClassLoader(agent.getClass().getClassLoader());
            agentThread.start();

            try {
                agentThread.join();
            } catch (InterruptedException ignore) {
            }

            if (error[0] == null) {
                agent = null;
                log.info("Embedded RHQ Agent has been stopped!");
            } else {
                log.warn(error[0].toString());
                throw error[0];
            }

            return;
        }
    }

    // this method is needed so the JBossAS server will call it when shutting down the service
    public void stop() throws Exception {
        stopAgent();
    }

    public boolean isAgentStarted() {
        return agent != null;
    }

    public String getAgentEnabled() {
        //-- if this method signature changes, you must also change StartupServlet.startEmbeddedAgent
        return enabled.toString();
    }

    public void setAgentEnabled(String flag) {
        enabled = Boolean.valueOf(flag.trim());
    }

    public String getResetConfiguration() {
        return resetConfigurationAtStartup.toString();
    }

    public void setResetConfiguration(String flag) {
        resetConfigurationAtStartup = Boolean.valueOf(flag.trim());
    }

    public File getEmbeddedAgentDirectory() {
        return embeddedAgentDirectory;
    }

    public void setEmbeddedAgentDirectory(File directory) {
        if (!directory.exists() || !directory.isDirectory()) {
            enabled = Boolean.FALSE;
            embeddedAgentDirectory = null;
            throw new IllegalArgumentException(
                "Invalid embedded agent directory specified - embedded agent has been disabled: " + directory);
        }

        embeddedAgentDirectory = directory;
    }

    public String getConfigurationFile() {
        return configFile;
    }

    public void setConfigurationFile(String location) {
        configFile = StringPropertyReplacer.replaceProperties(location);
    }

    public String getPreferencesNodeName() {
        return preferencesNodeName;
    }

    public void setPreferencesNodeName(String node) {
        if (node == null || node.trim().length() == 0) {
            try {
                node = InetAddress.getLocalHost().getCanonicalHostName();
            } catch (UnknownHostException e) {
                node = "${jboss.bind.address}";
            }
            node = node + "-embedded";
        }
        preferencesNodeName = StringPropertyReplacer.replaceProperties(node);
        System.setProperty("rhq.server.embedded-agent.preferences-node", preferencesNodeName);
    }

    public Properties getConfigurationOverrides() {
        //-- if this method signature changes, you must also change StartupServlet.startEmbeddedAgent
        return configurationOverrides;
    }

    public void setConfigurationOverrides(Properties overrides) {
        //-- if this method signature changes, you must also change StartupServlet.startEmbeddedAgent
        configurationOverrides = overrides;

        // perform some checking to setup defaults if need be

        String agent_name = configurationOverrides.getProperty("rhq.agent.name", "");
        if (agent_name.trim().length() == 0 || "-".equals(agent_name)) {
            // the rhq.server.embedded-agent.name was not specified or left blank,
            // that is the system propery used to define the rhq.agent.name - so let's create our own name
            try {
                agent_name = InetAddress.getLocalHost().getCanonicalHostName();
            } catch (UnknownHostException e) {
                agent_name = "${jboss.bind.address}";
            }
            agent_name = agent_name + "-embedded";
            agent_name = StringPropertyReplacer.replaceProperties(agent_name);
            configurationOverrides.put("rhq.agent.name", agent_name);
        }
    }

    public String[] getAgentArguments() {
        ArrayList<String> args = new ArrayList<String>();

        if (arguments != null) {
            for (String argument : arguments) {
                args.add(argument);
            }
        }

        args.add("--pref=" + getPreferencesNodeName());

        return args.toArray(new String[args.size()]);
    }

    public void setAgentArguments(String[] args) {
        arguments = args;
    }

    public void reloadAgentConfiguration() throws Exception {
        getPreferencesNode().clear();

        prepareConfigurationPreferences();
    }

    public void cleanDataDirectory() {
        AgentConfiguration config = new AgentConfiguration(getPreferencesNode());
        File data_dir = config.getDataDirectory();

        cleanDataFile(data_dir);

        // it is conceivable the comm services data directory was configured in a different
        // place than where the agent's data directory is - make sure we clean out that other data dir
        File comm_data_dir = config.getServiceContainerPreferences().getDataDirectory();
        if (!comm_data_dir.getAbsolutePath().equals(data_dir.getAbsolutePath())) {
            cleanDataFile(comm_data_dir);
        }

        return;
    }

    public Properties getAgentConfiguration() {
        try {
            Properties properties = new Properties();
            Preferences prefs = getPreferencesNode();

            for (String key : prefs.keys()) {
                properties.setProperty(key, prefs.get(key, "?"));
            }

            return properties;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public void executeAgentPromptCommand(final String command) {
        // all this funky threading/reflection is so we execute the command
        // in the isoloated context of the embedded agent
        final Exception[] error = new Exception[] { null };
        final Runnable agentRunnable = new Runnable() {
            public void run() {
                try {
                    Class<?> agentClass = agent.getClass();
                    Method agentMethod = agentClass.getMethod("executePromptCommand", new Class[] { String.class });

                    agentMethod.invoke(agent, new Object[] { command });
                } catch (Throwable t) {
                    error[0] = new Exception("Cannot execute embedded agent prompt command [" + command + "]. Cause: "
                        + t);
                }
            }
        };

        // create our thread that executes the agent command with the isolated class loader as its context
        Thread agentThread = new Thread(agentRunnable, "Embedded RHQ Agent Prompt Command");
        agentThread.setDaemon(true);
        agentThread.setContextClassLoader(agent.getClass().getClassLoader());
        agentThread.start();

        try {
            agentThread.join();
            if (error[0] == null) {
                log.info("Embedded agent executed the command [" + command
                    + "] - see the embedded agent output file for the results");
            } else {
                log.warn(error[0].toString());
            }
        } catch (InterruptedException ignore) {
        }

        return;
    }

    /**
     * This will return URLs to all of the jars and resources that the embedded agent will have access to in its
     * isolated classloader. Only those classes within these jars will be accessible to the embedded agent. Even classes
     * that are in this bootstrap service are not going to be available to it.
     *
     * @return URLs to embedded agent jars that will be in the embedded agent's classloader
     *
     * @throws Exception if failed to find the jars
     */
    private URL[] getEmbeddedAgentClasspath() throws Exception {
        File lib = new File(embeddedAgentDirectory, "lib");

        if (!lib.exists() || !lib.isDirectory()) {
            throw new Exception("There is no lib directory under [" + embeddedAgentDirectory
                + "]; cannot get agent jars");
        }

        File[] jarFiles = lib.listFiles();
        ArrayList<URL> classpathUrls = new ArrayList<URL>(jarFiles.length + 1);

        classpathUrls.add(embeddedAgentDirectory.toURI().toURL()); // allows the agent to find resourcees in here, like log4j.xml

        for (File jarFile : jarFiles) {
            classpathUrls.add(jarFile.toURI().toURL());
        }

        return classpathUrls.toArray(new URL[classpathUrls.size()]);
    }

    /**
     * Returns URLs to all directories where the agent's native libraries can be found.
     *
     * @return URLs where native libraries can be found
     *
     * @throws Exception if failed to find the native library directories
     */
    private URL[] getEmbeddedAgentNativeLibraryDirectories() throws Exception {
        ArrayList<URL> nativeLibraryDirUrls = new ArrayList<URL>();

        // we will assume our native libraries are under the same lib directory where the jars are
        // we will also look for native libraries in subdirectories under lib
        File lib = new File(embeddedAgentDirectory, "lib");

        if (!lib.exists() || !lib.isDirectory()) {
            throw new Exception("There is no lib directory under [" + embeddedAgentDirectory
                + "]; cannot get native library directories");
        }

        addDirectories(lib, nativeLibraryDirUrls);

        return nativeLibraryDirUrls.toArray(new URL[nativeLibraryDirUrls.size()]);
    }

    private void addDirectories(File dir, ArrayList<URL> list) throws Exception {
        if (dir.isDirectory()) {
            // add the given directory to the list and add all its subdirectories to the list
            list.add(dir.toURI().toURL());

            for (File dirEntry : dir.listFiles()) {
                addDirectories(dirEntry, list);
            }
        }

        return;
    }

    /**
     * This will ensure the agent's configuration preferences are populated. If need be, the configuration file is
     * loaded and all overrides are overlaid on top of the preferences. The preferences are also upgraded to ensure they
     * conform to the latest configuration schema version.
     *
     * @return the agent configuration
     *
     * @throws Exception
     */
    private AgentConfiguration prepareConfigurationPreferences() throws Exception {
        Preferences preferences_node = getPreferencesNode();
        AgentConfiguration config = new AgentConfiguration(preferences_node);

        if (config.getAgentConfigurationVersion() == 0) {
            config = loadConfigurationFile();
        }

        // now that the configuration preferences are loaded, we need to override them with any bootstrap override properties
        Properties overrides = getConfigurationOverrides();
        if (overrides != null) {
            for (Map.Entry<Object, Object> entry : overrides.entrySet()) {
                String key = entry.getKey().toString();
                String value = entry.getValue().toString();

                // allow ${var} notation in the values so we can provide variable replacements in the values
                value = StringPropertyReplacer.replaceProperties(value);

                preferences_node.put(key, value);
            }
        }

        // let's make sure our configuration is upgraded to the latest schema
        AgentConfigurationUpgrade.upgradeToLatest(config.getPreferences());

        return config;
    }

    /**
     * Loads the {@link #getConfigurationFile() configuration file}. The file location will first be checked for
     * existence on the file system and then as a URL. If it cannot be found, it will be assumed the file location
     * specifies the file as found in the current class loader and the file will be searched there. An exception is
     * thrown if the file cannot be found anywhere.
     *
     * @return the configuration that was loaded
     *
     * @throws IOException                       if failed to load the configuration file
     * @throws InvalidPreferencesFormatException if the configuration file had an invalid format
     * @throws BackingStoreException             if failed to access the preferences persistence store
     * @throws Exception                         on other failures
     */
    private AgentConfiguration loadConfigurationFile() throws Exception {
        String file_name = getConfigurationFile();
        String preferences_node_name = getPreferencesNodeName();
        InputStream config_file_input_stream = null;

        // first see if the file was specified as a path on the local file system
        try {
            File config_file = new File(file_name);

            if (config_file.exists()) {
                config_file_input_stream = new FileInputStream(config_file);
            }
        } catch (Exception e) {
            // isn't really an error - this just isn't a file on the local file system
        }

        // see if the file was specified as a URL
        if (config_file_input_stream == null) {
            try {
                URL config_file = new URL(file_name);

                config_file_input_stream = config_file.openStream();
            } catch (Exception e) {
                // isn't really an error - this just isn't a URL
            }
        }

        // if neither a file path or URL, assume the config file can be found in the classloader
        if (config_file_input_stream == null) {
            config_file_input_stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(file_name);
        }

        if (config_file_input_stream == null) {
            throw new IOException("Bad config file: " + file_name);
        }

        // We need to clear out any previous configuration in case the current config file doesn't specify a preference
        // that already exists in the preferences node.  In this case, the configuration file wants to fall back on the
        // default value and if we don't clear the preferences, we aren't guaranteed the value stored in the backing
        // store is the default value.
        // But first we need to backup these original preferences in case the config file fails to load -
        // we'll restore the original values in that case.

        Preferences preferences_node = getPreferencesNode();
        ByteArrayOutputStream backup = new ByteArrayOutputStream();
        preferences_node.exportSubtree(backup);
        preferences_node.clear();

        // now load in the preferences
        try {
            ByteArrayOutputStream raw_config_file = new ByteArrayOutputStream();
            StreamUtil.copy(config_file_input_stream, raw_config_file, true);
            String new_config = StringPropertyReplacer.replaceProperties(raw_config_file.toString());
            ByteArrayInputStream new_config_input_stream = new ByteArrayInputStream(new_config.getBytes());
            Preferences.importPreferences(new_config_input_stream);

            if (new AgentConfiguration(preferences_node).getAgentConfigurationVersion() == 0) {
                throw new IllegalArgumentException("Bad node name: " + preferences_node_name);
            }
        } catch (Exception e) {
            // a problem occurred importing the config file; let's restore our original values
            try {
                Preferences.importPreferences(new ByteArrayInputStream(backup.toByteArray()));
            } catch (Exception e1) {
                // its conceivable the same problem occurred here as with the original exception (backing store problem?)
                // let's throw the original exception, not this one
            }

            throw e;
        }

        AgentConfiguration agent_configuration = new AgentConfiguration(preferences_node);

        return agent_configuration;
    }

    /**
     * Returns the preferences for this agent. The node returned is where all preferences are to be stored.
     *
     * @return the agent preferences
     */
    private Preferences getPreferencesNode() {
        Preferences top_node = Preferences.userRoot().node(AgentConfigurationConstants.PREFERENCE_NODE_PARENT);
        Preferences preferences_node = top_node.node(getPreferencesNodeName());

        return preferences_node;
    }

    /**
     * This will delete the given file and if its a directory, will recursively delete its contents and its
     * subdirectories.
     *
     * @param file the file/directory to delete
     */
    private void cleanDataFile(File file) {
        boolean deleted;

        File[] doomed_files = file.listFiles();
        if (doomed_files != null) {
            for (File doomed_file : doomed_files) {
                cleanDataFile(doomed_file); // call this method recursively
            }
        }

        deleted = file.delete();

        if (!deleted) {
            log.warn("Cannot clean data file [" + file + "]");
        }

        return;
    }
}