/*
 * 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.core.plugin;

import java.io.File;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.InputStream;
import java.net.URL;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.sql.DataSource;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.maven.artifact.versioning.ComparableVersion;

import org.rhq.core.domain.configuration.Configuration;
import org.rhq.core.domain.configuration.definition.ConfigurationDefinition;
import org.rhq.core.domain.plugin.PluginKey;
import org.rhq.core.domain.plugin.PluginStatusType;
import org.rhq.core.domain.plugin.ServerPlugin;
import org.rhq.core.util.MessageDigestGenerator;
import org.rhq.core.util.jdbc.JDBCUtil;
import org.rhq.core.util.stream.StreamUtil;
import org.rhq.enterprise.server.auth.SubjectManagerLocal;
import org.rhq.enterprise.server.plugin.ServerPluginsLocal;
import org.rhq.enterprise.server.plugin.pc.AbstractTypeServerPluginContainer;
import org.rhq.enterprise.server.plugin.pc.MasterServerPluginContainer;
import org.rhq.enterprise.server.plugin.pc.ServerPluginType;
import org.rhq.enterprise.server.util.LookupUtil;
import org.rhq.enterprise.server.xmlschema.ServerPluginDescriptorMetadataParser;
import org.rhq.enterprise.server.xmlschema.ServerPluginDescriptorUtil;
import org.rhq.enterprise.server.xmlschema.generated.serverplugin.ServerPluginDescriptorType;

/**
 * This looks at both the file system and the database for new server plugins.
 *
 * @author John Mazzitelli
 */
public class ServerPluginScanner {
    private Log log = LogFactory.getLog(ServerPluginScanner.class);

    /** a list of server plugins found on previous scans that have not yet been processed */
    private List<File> scanned = new ArrayList<File>();

    /** Maintains a cache of what we had on the filesystem during the last scan */
    private Map<File, PluginWithDescriptor> serverPluginsOnFilesystem = new HashMap<File, PluginWithDescriptor>();

    /** directory where the server plugin jar files are found */
    private File serverPluginDir;

    public ServerPluginScanner() {
    }

    public File getServerPluginDir() {
        return this.serverPluginDir;
    }

    public void setServerPluginDir(File dir) {
        this.serverPluginDir = dir;
    }

    /**
     * This should be called after a call to {@link #serverPluginScan()} to register
     * plugins that were found in the scan.
     * 
     * This will also check to see if previously registered plugins changed their state.
     *
     * @throws Exception
     */
    void registerServerPlugins() throws Exception {
        for (File file : this.scanned) {
            log.debug("Deploying server plugin [" + file + "]...");
            registerServerPlugin(file);
        }
        this.scanned.clear();

        // we now have to see if the state of existing plugins have changed in the DB
        ServerPluginsLocal serverPluginsManager = LookupUtil.getServerPlugins();
        List<ServerPlugin> allPlugins = serverPluginsManager.getAllServerPlugins();
        List<ServerPlugin> installedPlugins = new ArrayList<ServerPlugin>();
        List<ServerPlugin> undeployedPlugins = new ArrayList<ServerPlugin>();
        for (ServerPlugin plugin : allPlugins) {
            if (plugin.getStatus() == PluginStatusType.INSTALLED) {
                installedPlugins.add(plugin);
            } else {
                undeployedPlugins.add(plugin);
            }
        }

        // first, have any plugins been undeployed since we last checked?
        for (ServerPlugin undeployedPlugin : undeployedPlugins) {
            File undeployedFile = new File(this.getServerPluginDir(), undeployedPlugin.getPath());
            PluginWithDescriptor pluginWithDescriptor = this.serverPluginsOnFilesystem.get(undeployedFile);
            if (pluginWithDescriptor != null) {
                try {
                    log.info("Plugin file [" + undeployedFile + "] has been undeployed. It will be deleted.");
                    List<Integer> id = Arrays.asList(new Integer(undeployedPlugin.getId()));
                    serverPluginsManager.undeployServerPlugins(LookupUtil.getSubjectManager().getOverlord(), id);
                } finally {
                    this.serverPluginsOnFilesystem.remove(undeployedFile);
                }
            }
        }

        // now see if any plugins changed state from enabled->disabled or vice versa
        // this also checks to see if the plugin configuration has changed since it was last loaded 
        MasterServerPluginContainer master = LookupUtil.getServerPluginService().getMasterPluginContainer();
        if (master != null) {
            for (ServerPlugin installedPlugin : installedPlugins) {
                PluginKey key = PluginKey.createServerPluginKey(installedPlugin.getType(), installedPlugin.getName());
                AbstractTypeServerPluginContainer pc = master.getPluginContainerByPlugin(key);
                if (pc != null && pc.isPluginLoaded(key)) {
                    boolean needToReloadPlugin = false;
                    boolean currentlyEnabled = pc.isPluginEnabled(key);
                    if (installedPlugin.isEnabled() != currentlyEnabled) {
                        log.info("Detected a state change to plugin [" + key + "]. It will now be "
                            + ((installedPlugin.isEnabled()) ? "[enabled]" : "[disabled]"));
                        needToReloadPlugin = true;
                    } else {
                        Long pluginLoadTime = pc.getPluginLoadTime(key);
                        if (pluginLoadTime != null) {
                            long configChangeTimestamp = serverPluginsManager
                                .getLastConfigurationChangeTimestamp(installedPlugin.getId());
                            if (configChangeTimestamp > pluginLoadTime.longValue()) {
                                // since the last time the plugin was loaded, its configuration has changed, reload it to pick up the new config 
                                log.info("Detected a config change to plugin [" + key + "]. It will be reloaded and "
                                    + ((installedPlugin.isEnabled()) ? "[enabled]" : "[disabled]"));
                                needToReloadPlugin = true;
                            }
                        }
                    }
                    if (needToReloadPlugin) {
                        pc.reloadPlugin(key, installedPlugin.isEnabled());
                    }
                }
            }
        }

        return;
    }

    /**
     * This method just scans the filesystem and DB for server plugin changes but makes
     * no attempt to register the plugins.
     * 
     * @throws Exception
     */
    void serverPluginScan() throws Exception {
        log.debug("Scanning for server plugins");

        if (this.getServerPluginDir() == null || !this.getServerPluginDir().isDirectory()) {
            // nothing to do since there is no plugin directory configured
            return;
        }

        // ensure that the filesystem and database are in a consistent state
        List<File> updatedFiles1 = serverPluginScanFilesystem();
        List<File> updatedFiles2 = serverPluginScanDatabase();

        // process any newly detected plugins
        List<File> allUpdatedFiles = new ArrayList<File>();
        allUpdatedFiles.addAll(updatedFiles1);
        allUpdatedFiles.addAll(updatedFiles2);

        for (File updatedFile : allUpdatedFiles) {
            log.debug("Scan detected server plugin [" + updatedFile + "]...");
            this.scanned.add(updatedFile);
        }
        return;
    }

    /**
     * This is called when a server plugin jar has been found on the filesystem that hasn't been seen yet
     * during this particular lifetime of the scanner. This does not necessarily mean its a new plugin jar,
     * it only means this is the first time we've seen it since this object has been instantiated.
     * This method will check to see if the database record matches the new plugin file and if so, does nothing.
     * 
     * @param file the new server plugin file
     */
    private void registerServerPlugin(File pluginFile) {
        try {
            ServerPluginDescriptorType descriptor;
            descriptor = this.serverPluginsOnFilesystem.get(pluginFile).descriptor;

            String pluginName = descriptor.getName();
            String displayName = descriptor.getDisplayName();

            ComparableVersion version; // this must be non-null, the next line ensures this
            version = ServerPluginDescriptorUtil.getPluginVersion(pluginFile, descriptor);

            log.debug("Registering server plugin [" + pluginName + "], version " + version);

            ServerPlugin plugin = new ServerPlugin(pluginName, pluginFile.getName());
            plugin.setDisplayName((displayName != null) ? displayName : pluginName);
            plugin.setEnabled(!descriptor.isDisabledOnDiscovery());
            plugin.setDescription(descriptor.getDescription());
            plugin.setMtime(pluginFile.lastModified());
            plugin.setVersion(version.toString());
            plugin.setAmpsVersion(descriptor.getApiVersion());
            plugin.setMD5(MessageDigestGenerator.getDigestString(pluginFile));
            plugin.setPluginConfiguration(getDefaultPluginConfiguration(descriptor));
            plugin.setScheduledJobsConfiguration(getDefaultScheduledJobsConfiguration(descriptor));
            plugin.setType(new ServerPluginType(descriptor).stringify());

            if (descriptor.getHelp() != null && !descriptor.getHelp().getContent().isEmpty()) {
                plugin.setHelp(String.valueOf(descriptor.getHelp().getContent().get(0)));
            }

            ServerPluginsLocal serverPluginsManager = LookupUtil.getServerPlugins();

            // see if this plugin has been deleted previously; if so, don't register and delete the file
            PluginKey newPluginKey = new PluginKey(plugin);
            PluginStatusType status = serverPluginsManager.getServerPluginStatus(newPluginKey);

            if (PluginStatusType.DELETED == status) {
                log.warn("Plugin file [" + pluginFile + "] has been detected but that plugin with name [" + pluginName
                    + "] was previously undeployed. Will not re-register that plugin and the file will be deleted.");
                pluginFile.delete();
            } else {
                // now attempt to register the plugin. "dbPlugin" will be the new updated plugin; but if
                // the scanned plugin does not obsolete the current plugin, then dbPlugin will be the old, still valid, plugin.
                SubjectManagerLocal subjectManager = LookupUtil.getSubjectManager();
                ServerPlugin dbPlugin = serverPluginsManager.registerServerPlugin(subjectManager.getOverlord(), plugin,
                    pluginFile);
                log.info("Registered server plugin [" + dbPlugin.getName() + "], version " + dbPlugin.getVersion());
            }
        } catch (Exception e) {
            log.error("Failed to register server plugin file [" + pluginFile + "]", e);
        }
        return;
    }

    private Configuration getDefaultPluginConfiguration(ServerPluginDescriptorType descriptor) throws Exception {
        Configuration defaults = null;
        ConfigurationDefinition def = ServerPluginDescriptorMetadataParser.getPluginConfigurationDefinition(descriptor);
        if (def != null) {
            defaults = def.getDefaultTemplate().createConfiguration();
        }
        return defaults;
    }

    private Configuration getDefaultScheduledJobsConfiguration(ServerPluginDescriptorType descriptor) throws Exception {
        Configuration defaults = null;
        ConfigurationDefinition def = ServerPluginDescriptorMetadataParser.getScheduledJobsDefinition(descriptor);
        if (def != null) {
            defaults = def.getDefaultTemplate().createConfiguration();
        }
        return defaults;
    }

    /**
     * Scans the plugin directory and updates our cache of known plugin files.
     * This will purge any old plugins that are deemed obsolete.
     * 
     * @return a list of files that appear to be new or updated and should be deployed
     */
    private List<File> serverPluginScanFilesystem() {
        List<File> updated = new ArrayList<File>();

        // get the current list of plugins deployed on the filesystem
        File[] pluginJars = this.getServerPluginDir().listFiles(new FilenameFilter() {
            public boolean accept(File dir, String name) {
                return name.endsWith(".jar");
            }
        });

        // refresh our cache so it reflects what is currently on the filesystem
        // first we remove any jar files in our cache that we no longer have on the filesystem
        ArrayList<File> doomedPluginFiles = new ArrayList<File>();
        for (File cachedPluginFile : this.serverPluginsOnFilesystem.keySet()) {
            boolean existsOnFileSystem = false;
            for (File filesystemPluginFile : pluginJars) {
                if (cachedPluginFile.equals(filesystemPluginFile)) {
                    existsOnFileSystem = true;
                    break; // our cached jar still exists on the file system
                }
            }
            if (!existsOnFileSystem) {
                doomedPluginFiles.add(cachedPluginFile); // this plugin file has been deleted from the filesystem, remove it from the cache
            }
        }
        for (File deletedPluginFile : doomedPluginFiles) {
            this.serverPluginsOnFilesystem.remove(deletedPluginFile);
        }

        // now insert new cache items representing new jar files and update existing ones as appropriate
        for (File pluginJar : pluginJars) {
            String md5 = null;

            PluginWithDescriptor pluginWithDescriptor = this.serverPluginsOnFilesystem.get(pluginJar);
            ServerPlugin plugin = null;
            if (pluginWithDescriptor != null) {
                plugin = pluginWithDescriptor.plugin;
            }

            try {
                if (plugin != null) {
                    if (pluginJar.lastModified() == 0L) {
                        // for some reason the operating system can't give us the last mod time, we need to do MD5 check
                        md5 = MessageDigestGenerator.getDigestString(pluginJar);
                        if (!md5.equals(plugin.getMd5())) {
                            plugin = null; // this plugin jar has changed - force it to refresh the cache.
                        }
                    } else if (pluginJar.lastModified() != plugin.getMtime()) {
                        plugin = null; // this plugin jar has changed - force it to refresh the cache.
                    }
                }

                if (plugin == null) {
                    cacheFilesystemServerPluginJar(pluginJar, md5);
                    updated.add(pluginJar);
                }
            } catch (Exception e) {
                log.warn("Failed to scan server plugin [" + pluginJar + "] found on filesystem. Skipping. Cause: " + e);
                this.serverPluginsOnFilesystem.remove(pluginJar); // act like we never saw it
                updated.remove(pluginJar);
            }
        }

        // Let's check to see if there are any obsolete plugins that need to be deleted.
        // This is needed if plugin-A-1.0.jar exists and someone deployed plugin-A-1.1.jar but fails to delete plugin-A-1.0.jar.
        doomedPluginFiles.clear();
        HashMap<String, ServerPlugin> pluginsByName = new HashMap<String, ServerPlugin>(); // key on (name+type), not just name
        for (Entry<File, PluginWithDescriptor> currentPluginFileEntry : this.serverPluginsOnFilesystem.entrySet()) {
            ServerPlugin currentPlugin = currentPluginFileEntry.getValue().plugin;
            ServerPlugin existingPlugin = pluginsByName.get(currentPlugin.getName() + currentPlugin.getType());
            if (existingPlugin == null) {
                // this is the usual case - this is the only plugin with the given name we've seen
                pluginsByName.put(currentPlugin.getName() + currentPlugin.getType(), currentPlugin);
            } else {
                ServerPlugin obsolete = ServerPluginDescriptorUtil.determineObsoletePlugin(currentPlugin,
                    existingPlugin);
                if (obsolete == null) {
                    obsolete = currentPlugin; // both were identical, but we only want one file so pick one to get rid of
                }
                doomedPluginFiles.add(new File(this.getServerPluginDir(), obsolete.getPath()));
                if (obsolete == existingPlugin) { // yes use == for reference equality!
                    pluginsByName.put(currentPlugin.getName() + currentPlugin.getType(), currentPlugin); // override the original one we saw with this latest one
                }
            }
        }

        // now we need to actually delete any obsolete plugin files from the file system
        for (File doomedPluginFile : doomedPluginFiles) {
            if (doomedPluginFile.delete()) {
                log.info("Deleted an obsolete server plugin file: " + doomedPluginFile);
                this.serverPluginsOnFilesystem.remove(doomedPluginFile);
                updated.remove(doomedPluginFile);
            } else {
                log.warn("Failed to delete what was deemed an obsolete server plugin file: " + doomedPluginFile);
            }
        }

        return updated;
    }

    /**
     * Creates a {@link ServerPlugin} object for the given plugin jar and caches it.
     * @param pluginJar information about this plugin jar will be cached
     * @param md5 if known, this is the plugin jar's MD5, <code>null</code> if not known
     * @return the plugin jar files's information that has been cached
     * @throws Exception if failed to get information about the plugin
     */
    private ServerPlugin cacheFilesystemServerPluginJar(File pluginJar, String md5) throws Exception {
        if (md5 == null) { // don't calculate the MD5 is we've already done it before
            md5 = MessageDigestGenerator.getDigestString(pluginJar);
        }
        URL pluginUrl = pluginJar.toURI().toURL();
        ServerPluginDescriptorType descriptor = ServerPluginDescriptorUtil.loadPluginDescriptorFromUrl(pluginUrl);
        String version = ServerPluginDescriptorUtil.getPluginVersion(pluginJar, descriptor).toString();
        String name = descriptor.getName();
        ServerPlugin plugin = new ServerPlugin(name, pluginJar.getName());
        plugin.setType(new ServerPluginType(descriptor).stringify());
        plugin.setMd5(md5);
        plugin.setVersion(version);
        plugin.setMtime(pluginJar.lastModified());
        this.serverPluginsOnFilesystem.put(pluginJar, new PluginWithDescriptor(plugin, descriptor));
        return plugin;
    }

    /**
     * This method scans the database for any new or updated server plugins and make sure this server
     * has a plugin file on the filesystem for each of those new/updated server plugins.
     *
     * This also checks to see if the enabled flag changed for plugins that we already know about.
     * If it does, and its plugin container has the plugin already loaded, the plugin will be reloaded.
     *
     * @return a list of files that appear to be new or updated and should be deployed
     */
    private List<File> serverPluginScanDatabase() throws Exception {
        // these are plugins (name/path/md5/mtime) that have changed in the DB but are missing from the file system
        List<ServerPlugin> updatedPlugins = new ArrayList<ServerPlugin>();

        // the same list as above, only they are the files that are written to the filesystem and no longer missing
        List<File> updatedFiles = new ArrayList<File>();

        // process all the installed plugins
        ServerPluginsLocal serverPluginsManager = LookupUtil.getServerPlugins();
        List<ServerPlugin> installedPlugins = serverPluginsManager.getServerPlugins();
        for (ServerPlugin installedPlugin : installedPlugins) {
            String name = installedPlugin.getName();
            String path = installedPlugin.getPath();
            String md5 = installedPlugin.getMd5();
            long mtime = installedPlugin.getMtime();
            String version = installedPlugin.getVersion();
            ServerPluginType pluginType = new ServerPluginType(installedPlugin.getType());

            // let's see if we have this logical plugin on the filesystem (it may or may not be under the same filename)
            File expectedFile = new File(this.getServerPluginDir(), path);
            File currentFile = null; // will be non-null if we find that we have this plugin on the filesystem already
            PluginWithDescriptor pluginWithDescriptor = this.serverPluginsOnFilesystem.get(expectedFile);

            if (pluginWithDescriptor != null) {
                currentFile = expectedFile; // we have it where we are expected to have it
                if (!pluginWithDescriptor.plugin.getName().equals(name)
                    || !pluginType.equals(pluginWithDescriptor.pluginType)) {
                    // Happens if someone wrote a plugin of one type but later changed it to a different type (or changed names)
                    log.warn("For some reason, the server plugin file [" + expectedFile + "] is plugin ["
                        + pluginWithDescriptor.plugin.getName() + "] of type [" + pluginWithDescriptor.pluginType
                        + "] but the database says it should be [" + name + "] of type [" + pluginType + "]");
                } else {
                    log.debug("File system and db agree on server plugin location for [" + expectedFile + "]");
                }
            } else {
                // the plugin might still be on the file system but under a different filename, see if we can find it
                for (Map.Entry<File, PluginWithDescriptor> cacheEntry : this.serverPluginsOnFilesystem.entrySet()) {
                    if (cacheEntry.getValue().plugin.getName().equals(name)
                        && cacheEntry.getValue().pluginType.equals(pluginType)) {
                        currentFile = cacheEntry.getKey();
                        pluginWithDescriptor = cacheEntry.getValue();
                        log.info("Filesystem has a server plugin [" + name + "] at the file [" + currentFile
                            + "] which is different than where the DB thinks it should be [" + expectedFile + "]");
                        break; // we found it, no need to continue the loop
                    }
                }
            }

            if (pluginWithDescriptor != null && currentFile != null && currentFile.exists()) {
                ServerPlugin dbPlugin = new ServerPlugin(name, path);
                dbPlugin.setType(pluginType.stringify());
                dbPlugin.setMd5(md5);
                dbPlugin.setVersion(version);
                dbPlugin.setMtime(mtime);

                ServerPlugin obsoletePlugin = ServerPluginDescriptorUtil.determineObsoletePlugin(dbPlugin,
                    pluginWithDescriptor.plugin);

                if (obsoletePlugin == pluginWithDescriptor.plugin) { // yes use == for reference equality!
                    StringBuilder logMsg = new StringBuilder();
                    logMsg.append("Found server plugin [").append(name);
                    logMsg.append("] in the DB that is newer than the one on the filesystem: ");
                    logMsg.append("DB path=[").append(path);
                    logMsg.append("]; file path=[").append(currentFile.getName());
                    logMsg.append("]; DB MD5=[").append(md5);
                    logMsg.append("]; file MD5=[").append(pluginWithDescriptor.plugin.getMd5());
                    logMsg.append("]; DB version=[").append(version);
                    logMsg.append("]; file version=[").append(pluginWithDescriptor.plugin.getVersion());
                    logMsg.append("]; DB timestamp=[").append(new Date(mtime));
                    logMsg.append("]; file timestamp=[").append(new Date(pluginWithDescriptor.plugin.getMtime()));
                    logMsg.append("]");
                    log.info(logMsg.toString());

                    updatedPlugins.add(dbPlugin);

                    if (currentFile.delete()) {
                        log.info("Deleted the obsolete server plugin file to be updated: " + currentFile);
                        this.serverPluginsOnFilesystem.remove(currentFile);
                    } else {
                        log.warn("Failed to delete the obsolete (to-be-updated) server plugin: " + currentFile);
                    }
                    currentFile = null;
                } else if (obsoletePlugin == null) {
                    // the db is up-to-date, but update the cache so we don't check MD5 or parse the descriptor again
                    currentFile.setLastModified(mtime);
                    pluginWithDescriptor.plugin.setMtime(mtime);
                    pluginWithDescriptor.plugin.setVersion(version);
                    pluginWithDescriptor.plugin.setMd5(md5);
                } else {
                    log.info("It appears that the server plugin [" + dbPlugin
                        + "] in the database may be obsolete. If so, it will be updated later.");
                }
            } else {
                log.info("Found server plugin in the DB that we do not yet have: " + name);
                ServerPlugin plugin = new ServerPlugin(name, path, md5);
                plugin.setType(pluginType.stringify());
                plugin.setMtime(mtime);
                plugin.setVersion(version);
                updatedPlugins.add(plugin);
                this.serverPluginsOnFilesystem.remove(expectedFile); // paranoia, make sure the cache doesn't have this
            }
        }

        // write all our updated plugins to the file system
        if (!updatedPlugins.isEmpty()) {
            Connection conn = null;
            PreparedStatement ps = null;
            ResultSet rs = null;

            try {
                DataSource ds = LookupUtil.getDataSource();
                conn = ds.getConnection();

                ps = conn.prepareStatement("SELECT CONTENT FROM " + ServerPlugin.TABLE_NAME
                    + " WHERE DEPLOYMENT = 'SERVER' AND STATUS = 'INSTALLED' AND NAME = ? AND PTYPE = ?");
                for (ServerPlugin plugin : updatedPlugins) {
                    File file = new File(this.getServerPluginDir(), plugin.getPath());

                    ps.setString(1, plugin.getName());
                    ps.setString(2, plugin.getType());
                    rs = ps.executeQuery();
                    rs.next();
                    InputStream content = rs.getBinaryStream(1);
                    StreamUtil.copy(content, new FileOutputStream(file));
                    rs.close();
                    file.setLastModified(plugin.getMtime()); // so our file matches the database mtime
                    updatedFiles.add(file);

                    // we are writing a new file to the filesystem, cache it since we know about it now
                    cacheFilesystemServerPluginJar(file, null);
                }
            } finally {
                JDBCUtil.safeClose(conn, ps, rs);
            }
        }

        return updatedFiles;
    }

    private class PluginWithDescriptor {
        public PluginWithDescriptor(ServerPlugin plugin, ServerPluginDescriptorType descriptor) {
            this.plugin = plugin;
            this.descriptor = descriptor;
            this.pluginType = new ServerPluginType(descriptor);
        }

        public final ServerPlugin plugin;
        public final ServerPluginDescriptorType descriptor;
        public final ServerPluginType pluginType;
    }
}
