/*
 * RHQ Management Platform
 * Copyright (C) 2005-2009 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.plugin.pc;

import java.io.File;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

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

import org.rhq.core.domain.configuration.Configuration;
import org.rhq.core.domain.plugin.PluginKey;
import org.rhq.core.domain.plugin.ServerPlugin;
import org.rhq.enterprise.server.plugin.ServerPluginsLocal;
import org.rhq.enterprise.server.util.LookupUtil;
import org.rhq.enterprise.server.xmlschema.ScheduledJobDefinition;
import org.rhq.enterprise.server.xmlschema.ServerPluginDescriptorMetadataParser;
import org.rhq.enterprise.server.xmlschema.ServerPluginDescriptorUtil;
import org.rhq.enterprise.server.xmlschema.generated.serverplugin.ServerPluginDescriptorType;

/**
 * Provides functionality to manage plugins for a plugin container. Plugin containers
 * can install their own plugin managers that are extensions to this class if they need to.
 *
 * Most of the methods here are protected; they are meant for the plugin container's use only.
 * Usually, anything an external client needs will be done through a delegation method
 * found on the plugin container.
 *
 * @author John Mazzitelli
 */
//TODO: need a R/W lock to make this class thread safe
public class ServerPluginManager {
    private final Log log = LogFactory.getLog(this.getClass());

    private final AbstractTypeServerPluginContainer parentPluginContainer;

    /**
     * The map of all plugin environments keyed on plugin name.
     */
    private final Map<String, ServerPluginEnvironment> loadedPlugins;

    /**
     * Indicates which plugins are enabled and which are disabled; keyed on plugin name.
     */
    private final Map<String, Boolean> enabledPlugins;

    /**
     * To avoid having to create contexts multiple times for the same plugin,
     * contexts are cached here, keyed on plugin name.
     */
    private final Map<String, ServerPluginContext> pluginContextCache;

    /**
     * Cached instances of objects used to initialize and shutdown individual plugins.
     * Only plugins that declare their own plugin component will have objects in this cache.
     * This is keyed on plugin name.
     */
    private final Map<String, ServerPluginComponent> pluginComponentCache;

    /**
     * Creates a plugin manager for the given plugin container.
     *
     * @param pc the plugin manager's owning plugin container
     */
    public ServerPluginManager(AbstractTypeServerPluginContainer pc) {
        this.parentPluginContainer = pc;
        this.loadedPlugins = new HashMap<String, ServerPluginEnvironment>();
        this.pluginContextCache = new HashMap<String, ServerPluginContext>();
        this.pluginComponentCache = new HashMap<String, ServerPluginComponent>();
        this.enabledPlugins = new HashMap<String, Boolean>();
    }

    /**
     * Returns the plugin container that whose plugins are managed by this manager.
     *
     * @return the plugin container that owns this plugin manager
     */
    public AbstractTypeServerPluginContainer getParentPluginContainer() {
        return this.parentPluginContainer;
    }

    /**
     * Returns the {@link ServerPluginEnvironment}s for every plugin this manager has loaded.
     * The returned collection is a copy and not backed by this manager.
     *
     * @return environments for all the plugins
     */
    public Collection<ServerPluginEnvironment> getPluginEnvironments() {
        return new ArrayList<ServerPluginEnvironment>(this.loadedPlugins.values());
    }

    /**
     * Given a plugin name, this returns that plugin's environment.
     *
     * <p>The plugin's name is defined in its plugin descriptor - specifically the XML root node's "name" attribute
     * (e.g. &ltserver-plugin name="thePluginName").</p>
     *
     * @param pluginName the plugin whose environment is to be returned
     * @return given plugin's environment
     */
    public ServerPluginEnvironment getPluginEnvironment(String pluginName) {
        return this.loadedPlugins.get(pluginName);
    }

    /**
     * Initializes the plugin manager to prepare it to start loading plugins.
     *
     * @throws Exception if failed to initialize
     */
    protected void initialize() throws Exception {
        log.debug("Plugin manager initializing");
        return; // no-op
    }

    /**
     * Shuts down this manager. This should be called only after all of its plugins
     * have been {@link #unloadPlugin(ServerPluginEnvironment) unloaded}.
     */
    protected void shutdown() {
        log.debug("Plugin manager shutting down");

        if (this.loadedPlugins.size() > 0) {
            log.warn("Server plugin manager is being shutdown while some plugins are still loaded: "
                + this.loadedPlugins);
        }

        this.loadedPlugins.clear();
        this.pluginContextCache.clear();
        this.pluginComponentCache.clear();
        this.enabledPlugins.clear();

        return;
    }

    /**
     * Informs the plugin manager that a plugin with the given environment needs to be loaded.
     * Once this method returns, the plugin's components are ready to be created and used, unless
     * <code>enabled</code> is <code>false</code>, in which case the plugin will not
     * be initialized.
     *
     * @param env the environment of the plugin to be loaded
     * @param enabled <code>true</code> if the plugin should be initialized; <code>false</code> if
     *        the plugin's existence should be noted but it should not be initialized or started
     *
     * @throws Exception if the plugin manager cannot load the plugin or deems the plugin invalid.
     *                   Typically, this method will not throw an exception unless enabled is
     *                   <code>true</code> - loading a disabled plugin is trivial and should not
     *                   fail or throw an exception.
     */
    protected void loadPlugin(ServerPluginEnvironment env, boolean enabled) throws Exception {
        String pluginName = env.getPluginKey().getPluginName();
        log.debug("Loading server plugin [" + pluginName + "] from: " + env.getPluginUrl());

        if (enabled) {
            // tell the plugin we are loading it
            ServerPluginComponent component = createServerPluginComponent(env);
            if (component != null) {
                ServerPluginContext context = getServerPluginContext(env);
                log.debug("Initializing plugin component for server plugin [" + pluginName + "]");
                ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader();
                try {
                    Thread.currentThread().setContextClassLoader(env.getPluginClassLoader());
                    component.initialize(context);
                } catch (Throwable t) {
                    throw new Exception("Plugin component failed to initialize server plugin [" + pluginName + "]", t);
                } finally {
                    Thread.currentThread().setContextClassLoader(originalContextClassLoader);
                }
                this.pluginComponentCache.put(pluginName, component);
            }
        } else {
            log.info("Server plugin [" + pluginName + "] is loaded but disabled");
        }

        // note that we only cache it if the plugin component was successful
        this.loadedPlugins.put(pluginName, env);
        this.enabledPlugins.put(pluginName, Boolean.valueOf(enabled));

        return;
    }

    protected void startPlugins() {
        log.debug("Starting server plugins");
        for (String pluginName : this.pluginComponentCache.keySet()) {
            startPlugin(pluginName);
        }
        log.debug("Server plugins started.");
        return;
    }

    protected void startPlugin(String pluginName) {
        if (isPluginEnabled(pluginName)) {
            ServerPluginComponent component = this.pluginComponentCache.get(pluginName);
            if (component != null) {
                ServerPluginEnvironment env = this.loadedPlugins.get(pluginName);
                log.debug("Starting plugin component for server plugin [" + pluginName + "]");
                ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader();
                try {
                    if (env == null) {
                        throw new Exception("Plugin [" + pluginName + "] was never loaded");
                    }
                    Thread.currentThread().setContextClassLoader(env.getPluginClassLoader());
                    component.start();
                } catch (Throwable t) {
                    log.warn("Plugin component failed to start plugin [" + pluginName + "]", t);
                } finally {
                    Thread.currentThread().setContextClassLoader(originalContextClassLoader);
                }
            }
        }
        return;
    }

    protected void stopPlugins() {
        log.debug("Stopping server plugins");
        for (String pluginName : this.pluginComponentCache.keySet()) {
            stopPlugin(pluginName);
        }
        log.debug("Server plugins stopped.");
        return;
    }

    protected void stopPlugin(String pluginName) {
        ServerPluginComponent component = this.pluginComponentCache.get(pluginName);
        if (component != null) {
            ServerPluginEnvironment env = this.loadedPlugins.get(pluginName);
            log.debug("Stopping plugin component for server plugin [" + pluginName + "]");
            ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader();
            try {
                if (env == null) {
                    throw new Exception("Plugin [" + pluginName + "] was never loaded");
                }
                Thread.currentThread().setContextClassLoader(env.getPluginClassLoader());
                component.stop();
            } catch (Throwable t) {
                log.warn("Plugin component failed to stop plugin [" + pluginName + "]", t);
            } finally {
                Thread.currentThread().setContextClassLoader(originalContextClassLoader);
            }
        }
        return;
    }

    /**
     * Informs the plugin manager that a plugin with the given name is to be unloaded.
     * The component's shutdown method will be called.
     * 
     * @param pluginName the name of the plugin to be unloaded
     *
     * @throws Exception if the plugin manager cannot unload the plugin
     */
    protected void unloadPlugin(String pluginName) throws Exception {
        try {
            ServerPluginEnvironment env = getPluginEnvironment(pluginName);
            if (env == null) {
                log.debug("Server plugin [" + pluginName + "] was never loaded, ignoring unload request");
                return;
            }

            log.debug("Unloading server plugin [" + pluginName + "]");

            // tell the plugin we are unloading it
            ServerPluginComponent component = this.pluginComponentCache.get(pluginName);
            if (component != null) {
                log.debug("Shutting down plugin component for server plugin [" + pluginName + "]");
                ClassLoader originalContextClassLoader = Thread.currentThread().getContextClassLoader();
                try {
                    Thread.currentThread().setContextClassLoader(env.getPluginClassLoader());
                    component.shutdown();
                } catch (Throwable t) {
                    throw new Exception("Plugin component failed to shutdown server plugin [" + pluginName + "]", t);
                } finally {
                    Thread.currentThread().setContextClassLoader(originalContextClassLoader);
                    this.pluginComponentCache.remove(pluginName);
                }
            }
        } finally {
            this.loadedPlugins.remove(pluginName);
            this.enabledPlugins.remove(pluginName);
            this.pluginContextCache.remove(pluginName);
        }

        return;
    }

    /**
     * Informs the plugin manager that a plugin with the given name is to be unloaded.
     * Once this method returns, the plugin's components should not be created or used.
     *
     * If <code>keepClassLoader</code> is <code>true</code>, this is the same as
     * {@link #unloadPlugin(String)}.
     *
     * You want to keep the classloader if you are only temporarily unloading the plugin, and
     * will load it back soon.
     *
     * Subclasses of this plugin manager class will normally not override this method; instead,
     * they will typically want to override {@link #unloadPlugin(String)}.
     *
     * @param pluginName the name of the plugin to be unloaded
     * @param keepClassLoader if <code>true</code> the classloader is not destroyed
     * @throws Exception if the plugin manager cannot unload the plugin
     */
    protected void unloadPlugin(String pluginName, boolean keepClassLoader) throws Exception {
        try {
            unloadPlugin(pluginName);
        } finally {
            if (!keepClassLoader) {
                String pluginType = getParentPluginContainer().getSupportedServerPluginType().stringify();
                PluginKey pluginKey = PluginKey.createServerPluginKey(pluginType, pluginName);
                MasterServerPluginContainer master = this.parentPluginContainer.getMasterServerPluginContainer();
                master.getClassLoaderManager().unloadPlugin(pluginKey);
            }
        }

        return;
    }

    /**
     * This will reload a plugin allowing you to enable or disable it.
     * This will {@link #startPlugin(String) start the plugin component} if you enable it.
     * This will {@link #stopPlugin(String) stop the plugin component} if you disable it.
     * This will ensure any new plugin configuration will be re-loaded.
     *
     * @param pluginName the name of the loaded plugin that is to be enabled or disabled
     * @param enabled <code>true</code> if you want to enable the plugin; <code>false</code>
     *                if you want to disable it
     * @throws Exception if the plugin was never loaded before or the reload failed
     */
    protected void reloadPlugin(String pluginName, boolean enabled) throws Exception {
        if (enabled) {
            enablePlugin(pluginName);
        } else {
            disablePlugin(pluginName);
        }
        return;
    }

    protected void enablePlugin(String pluginName) throws Exception {
        log.info("Enabling server plugin [" + pluginName + "]");
        ServerPluginEnvironment env = getPluginEnvironment(pluginName);
        if (env == null) {
            throw new IllegalArgumentException("Server plugin [" + pluginName + "] was never loaded, cannot enable it");
        }
        stopPlugin(pluginName); // under normal circumstances, we should not need to do this, but just in case the plugin is somehow already started, stop it
        unloadPlugin(pluginName, true); // unloading it will clean up old data and force the plugin context to reload
        env = rebuildServerPluginEnvironment(env);
        try {
            // reload it in the enabled state.
            loadPlugin(env, true);
        } catch (Exception e) {
            // we've already unloaded it - so even though we failed to enable it, we need to load it back, albeit disabled
            loadPlugin(env, false);
            throw e;
        }
        startPlugin(pluginName); // since we are enabling the plugin, immediately start it
        return;
    }

    protected void disablePlugin(String pluginName) throws Exception {
        log.info("Disabling server plugin [" + pluginName + "]");
        ServerPluginEnvironment env = getPluginEnvironment(pluginName);
        if (env == null) {
            throw new IllegalArgumentException("Server plugin [" + pluginName + "] was never loaded, cannot disable it");
        }
        stopPlugin(pluginName);
        unloadPlugin(pluginName, true); // unloading it will clean up old data and force the plugin context to reload if we later re-enable it
        env = rebuildServerPluginEnvironment(env);
        loadPlugin(env, false); // re-load it in the disabled state
        return;
    }

    protected boolean isPluginLoaded(String pluginName) {
        return this.loadedPlugins.containsKey(pluginName);
    }

    protected boolean isPluginEnabled(String pluginName) {
        return Boolean.TRUE.equals(this.enabledPlugins.get(pluginName));
    }

    /**
     * Returns the main plugin component instance that is responsible for initializing and managing
     * the plugin. This will return <code>null</code> if a plugin has not defined a plugin component.
     *
     * @param pluginName the name of the plugin whose plugin component is to be returned
     *
     * @return the plugin component instance that initialized and is managing a plugin. Will
     *         return <code>null</code> if the plugin has not defined a plugin component.
     *         <code>null</code> is also returned if the plugin is not initialized yet.
     */
    protected ServerPluginComponent getServerPluginComponent(String pluginName) {
        return this.pluginComponentCache.get(pluginName);
    }

    protected Log getLog() {
        return this.log;
    }

    protected ServerPluginContext getServerPluginContext(ServerPluginEnvironment env) {

        String pluginName = env.getPluginKey().getPluginName();
        ServerPluginContext context = this.pluginContextCache.get(pluginName);

        // if we already created it, return it immediately and don't create another
        if (context != null) {
            return context;
        }

        MasterServerPluginContainer masterPC = this.parentPluginContainer.getMasterServerPluginContainer();
        MasterServerPluginContainerConfiguration masterConfig = masterPC.getConfiguration();
        File dataDir = new File(masterConfig.getDataDirectory(), pluginName);
        File tmpDir = masterConfig.getTemporaryDirectory();
        Configuration pluginConfig = null;
        List<ScheduledJobDefinition> schedules;
        try {
            ServerPlugin plugin = getPlugin(env);
            pluginConfig = plugin.getPluginConfiguration();
            Configuration scheduledJobsConfig = plugin.getScheduledJobsConfiguration();
            schedules = ServerPluginDescriptorMetadataParser.getScheduledJobs(scheduledJobsConfig);
        } catch (Exception e) {
            throw new RuntimeException("Failed to get plugin config/schedules from the database", e);
        }

        context = new ServerPluginContext(env, dataDir, tmpDir, pluginConfig, schedules);
        this.pluginContextCache.put(pluginName, context);
        return context;
    }

    /**
     * Given a plugin environment, this will rebuild a new one with up-to-date information.
     * This means the descriptor will be reparsed.
     *
     * @param env the original environment
     * @return the new environment that has been rebuild from the original but has newer data
     * @throws Exception if the environment could not be rebuilt - probably due to an invalid descriptor
     *                   in the plugin jar or the plugin jar is now missing
     */
    protected ServerPluginEnvironment rebuildServerPluginEnvironment(ServerPluginEnvironment env) throws Exception {
        URL url = env.getPluginUrl();
        ClassLoader classLoader = env.getPluginClassLoader();
        ServerPluginDescriptorType descriptor = ServerPluginDescriptorUtil.loadPluginDescriptorFromUrl(url);
        ServerPluginEnvironment newEnv = new ServerPluginEnvironment(url, classLoader, descriptor);
        return newEnv;
    }

    /**
     * Given a plugin environment, return its {@link ServerPlugin} representation, which should also include
     * the plugin configuration and scheduled jobs configuration.
     *
     * @param pluginEnv
     * @return the ServerPlugin object for the given plugin
     */
    protected ServerPlugin getPlugin(ServerPluginEnvironment pluginEnv) {
        // get the plugin data from the database
        ServerPluginsLocal serverPluginsManager = LookupUtil.getServerPlugins();
        ServerPlugin plugin = serverPluginsManager.getServerPlugin(pluginEnv.getPluginKey());
        plugin = serverPluginsManager.getServerPluginRelationships(plugin);
        return plugin;
    }

    /**
     * This will create a new {@link ServerPluginComponent} instance for that is used to
     * initialize and shutdown a particular server plugin. If there is no plugin component
     * configured for the given plugin, <code>null</code> is returned.
     *
     * The new object will be loaded in the plugin's specific classloader.
     *
     * @param environment the environment in which the plugin will execute
     *
     * @return a new object loaded in the proper plugin classloader that can initialize/shutdown the plugin,
     *         or <code>null</code> if there is no plugin component to be associated with the given plugin
     *
     * @throws Exception if failed to create the instance
     */
    protected ServerPluginComponent createServerPluginComponent(ServerPluginEnvironment environment) throws Exception {

        String pluginName = environment.getPluginKey().getPluginName();
        ServerPluginComponent instance = null;

        ServerPluginDescriptorType descriptor = environment.getPluginDescriptor();
        String className = ServerPluginDescriptorMetadataParser.getPluginComponentClassName(descriptor);

        if (className != null) {
            log.debug("Creating plugin component [" + className + "] for plugin [" + pluginName + "]");
            instance = (ServerPluginComponent) instantiatePluginClass(environment, className);
            log.debug("Plugin component created [" + instance.getClass() + "] for plugin [" + pluginName + "]");
        }

        return instance;
    }

    /**
     * Loads a class with the given name within the given environment's classloader.
     * The class will only be initialized if <code>initialize</code> is <code>true</code>.
     *
     * @param environment the environment that has the classloader where the class will be loaded
     * @param className the class to load
     * @param initialize whether the class must be initialized
     * @return the new class that has been loaded
     * @throws Exception if failed to load the class
     */
    protected Class<?> loadPluginClass(ServerPluginEnvironment environment, String className, boolean initialize)
        throws Exception {

        ClassLoader loader = environment.getPluginClassLoader();

        ServerPluginDescriptorType descriptor = environment.getPluginDescriptor();
        className = ServerPluginDescriptorMetadataParser.getFullyQualifiedClassName(descriptor, className);

        log.debug("Loading server plugin class [" + className + "]...");

        try {
            Class<?> clazz = Class.forName(className, initialize, loader);
            log.debug("Loaded server plugin class [" + clazz + "]. initialized=[" + initialize + ']');
            return clazz;
        } catch (ClassNotFoundException e) {
            throw new Exception("Could not find plugin class [" + className + "] from plugin environment ["
                + environment + "]", e);
        } catch (NullPointerException npe) {
            throw new Exception("Plugin class was 'null' in plugin environment [" + environment + "]", npe);
        }
    }

    /**
     * Instantiates a class with the given name within the given environment's classloader using
     * the class' no-arg constructor.
     *
     * @param environment the environment that has the classloader where the class will be loaded
     * @param className the class to instantiate
     * @return the new object that is an instance of the given class
     * @throws Exception if failed to instantiate the class
     */
    protected Object instantiatePluginClass(ServerPluginEnvironment environment, String className) throws Exception {
        try {
            Class<?> clazz = loadPluginClass(environment, className, true);
            log.debug("Instantiating server plugin class [" + clazz + "]");
            return clazz.newInstance();
        } catch (InstantiationException e) {
            throw new Exception("Could not instantiate plugin class [" + className + "] from plugin environment ["
                + environment + "]", e);
        } catch (IllegalAccessException e) {
            throw new Exception("Could not access plugin class [" + className + "] from plugin environment ["
                + environment + "]", e);
        }
    }
}
