package net.i2p.router.web;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.StringTokenizer;
import java.util.concurrent.ConcurrentHashMap;

import org.eclipse.jetty.server.Server;

import net.i2p.CoreVersion;
import net.i2p.I2PAppContext;
import net.i2p.app.ClientApp;
import net.i2p.app.ClientAppState;
import net.i2p.data.DataHelper;
import net.i2p.data.Base64;
import net.i2p.router.RouterContext;
import net.i2p.router.RouterVersion;
import net.i2p.router.startup.ClientAppConfig;
import net.i2p.router.startup.LoadClientAppsJob;
import net.i2p.router.update.ConsoleUpdateManager;
import static net.i2p.update.UpdateType.*;
import net.i2p.util.ConcurrentHashSet;
import net.i2p.util.FileSuffixFilter;
import net.i2p.util.FileUtil;
import net.i2p.util.I2PAppThread;
import net.i2p.util.Log;
import net.i2p.util.PortMapper;
import net.i2p.util.SimpleTimer2;
import net.i2p.util.SystemVersion;
import net.i2p.util.Translate;
import net.i2p.util.VersionComparator;

import org.eclipse.jetty.server.handler.ContextHandlerCollection;


/**
 *  Start/stop/delete plugins that are already installed
 *  Get properties of installed plugins
 *  Get or change settings in plugins.config
 *
 *  @since 0.7.12
 *  @author zzz
 */
public class PluginStarter implements Runnable {
    protected RouterContext _context;
    private static final String CONFIG_FILE = "plugins.config";
    public static final String PREFIX = "plugin.";
    // false, true, or deleted
    public static final String ENABLED = ".startOnLoad";
    public static final String DELETED = "deleted";
    public static final String PLUGIN_DIR = "plugins";
    private static final String[] STANDARD_WEBAPPS = { "i2psnark", "i2ptunnel", "imagegen", "susidns",
                                                       "susimail", "addressbook", "routerconsole" };
    private static final String[] STANDARD_THEMES = { "images", "light", "dark", "classic",
                                                      "midnight" };
    private static Map<String, ThreadGroup> pluginThreadGroups = new ConcurrentHashMap<String, ThreadGroup>();   // one thread group per plugin (map key=plugin name)
    private static Map<String, Collection<SimpleTimer2.TimedEvent>> _pendingPluginClients =
                   new ConcurrentHashMap<String, Collection<SimpleTimer2.TimedEvent>>();
    private static Map<String, ClassLoader> _clCache = new ConcurrentHashMap<String, ClassLoader>();
    private static Map<String, Collection<String>> pluginWars = new ConcurrentHashMap<String, Collection<String>>();

    /**
     *  Plugin name to plugin version of plugins that do not work
     *  with Jetty 9, but do not have a max-jetty-version=8.9999 set.
     *  Unmodifiable.
     *
     *  @since 0.9.30
     */
    public static final Map<String, String> jetty9Blacklist;

    static {
        Map<String, String> map = new HashMap<String, String>(2);
        map.put("i2pbote", "0.4.5");
        map.put("BwSchedule", "0.0.36");
        jetty9Blacklist = Collections.unmodifiableMap(map);
    }

    /**
     *  Plugin name to plugin version of plugins that do not work
     *  with Java 9+
     *  Unmodifiable.
     *
     *  @since 0.9.30
     */
    public static final Map<String, String> java9Blacklist;

    static {
        Map<String, String> map = new HashMap<String, String>(2);
        map.put("01_neodatis", "2.1-2.14-209-17");
        map.put("02_seedless", "0.1.7-0.1.12");
        java9Blacklist = Collections.unmodifiableMap(map);
    }

    public PluginStarter(RouterContext ctx) {
        _context = ctx;
    }

    /**
     *  @since public since 0.9.33, was package private
     */
    public static boolean pluginsEnabled(I2PAppContext ctx) {
         return ctx.getBooleanPropertyDefaultTrue("router.enablePlugins");
    }

    public void run() {
        deferredDeletePlugins(_context);
        if (_context.getBooleanPropertyDefaultTrue("plugins.autoUpdate") &&
            !NewsHelper.isUpdateInProgress()) {
            String prev = _context.getProperty("router.previousVersion");
            if (prev != null &&
                VersionComparator.comp(RouterVersion.VERSION, prev) > 0) {
                updateAll(_context, true);
            }
        }
        startPlugins(_context);
    }

    /**
     *  threaded
     *  @since 0.8.13, public since 0.9.33, was package private
     */
    public static void updateAll(RouterContext ctx) {
        Thread t = new I2PAppThread(new PluginUpdater(ctx), "PluginUpdater", true);
        t.start();
    }

    /**
     *  thread
     *  @since 0.8.13
     */
    private static class PluginUpdater implements Runnable {
        private final RouterContext _ctx;

        public PluginUpdater(RouterContext ctx) {
            _ctx = ctx;
        }

        public void run() {
            updateAll(_ctx, false);
        }
    }

    /**
     *  inline
     *  @since 0.8.13
     */
    private static void updateAll(RouterContext ctx, boolean delay) {
        List<String> plugins = getPlugins();
        Map<String, String> toUpdate = new HashMap<String, String>();
        for (String appName : plugins) {
            Properties props = pluginProperties(ctx, appName);
            String url = props.getProperty("updateURL");
            if (url != null)
                toUpdate.put(appName, url);
        }
        if (toUpdate.isEmpty())
            return;

        ConsoleUpdateManager mgr = UpdateHandler.updateManager(ctx);
        if (mgr == null)
            return;
        if (mgr.isUpdateInProgress())
            return;

        if (delay) {
            // wait for router.
            // i2ptunnel won't start until isRunning()
            int loop = 0;
            while (!ctx.router().isRunning()) {
                try {
                    Thread.sleep(10*1000);
                } catch (InterruptedException ie) { return; }
                // 30 minutes
                if (loop++ > 180) return;
            }
            // wait for proxy
            mgr.update(TYPE_DUMMY, 3*60*1000);
            mgr.notifyProgress(null, Messages.getString("Checking for plugin updates", ctx));
            loop = 0;
            do {
                try {
                    Thread.sleep(5*1000);
                } catch (InterruptedException ie) { break; }
                if (loop++ > 40) break;
            } while (mgr.isUpdateInProgress(TYPE_DUMMY));
        }

        String proxyHost = ctx.getProperty(ConfigUpdateHandler.PROP_PROXY_HOST, ConfigUpdateHandler.DEFAULT_PROXY_HOST);
        int proxyPort = ConfigUpdateHandler.proxyPort(ctx);
        if (proxyPort == ConfigUpdateHandler.DEFAULT_PROXY_PORT_INT &&
            proxyHost.equals(ConfigUpdateHandler.DEFAULT_PROXY_HOST) &&
            !ctx.portMapper().isRegistered(PortMapper.SVC_HTTP_PROXY)) {
            mgr.notifyComplete(null, Messages.getString("Plugin update check failed", ctx) +
                                     " - " +
                                     Messages.getString("HTTP client proxy tunnel must be running", ctx));
            return;
        }
        if (ctx.commSystem().isDummy()) {
            mgr.notifyComplete(null, Messages.getString("Plugin update check failed", ctx) +
                                     " - " +
                                     "VM Comm System");
            return;
        }

        Log log = ctx.logManager().getLog(PluginStarter.class);
        int updated = 0;
        for (Map.Entry<String, String> entry : toUpdate.entrySet()) {
            String appName = entry.getKey();
            if (log.shouldLog(Log.WARN))
                log.warn("Checking for update plugin: " + appName);

            // blocking
            if (mgr.checkAvailable(PLUGIN, appName, 60*1000) == null) {
                if (log.shouldLog(Log.WARN))
                    log.warn("No update available for plugin: " + appName);
                continue;
            }

            if (log.shouldLog(Log.WARN))
                log.warn("Updating plugin: " + appName);
            // non-blocking
            mgr.update(PLUGIN, appName, 30*60*1000);
            int loop = 0;
            do {
                // only wait for 4 minutes, then we will
                // keep going
                try {
                    Thread.sleep(5*1000);
                } catch (InterruptedException ie) {}
                if (loop++ > 48) break;
            } while (mgr.isUpdateInProgress(PLUGIN, appName));

            if (mgr.getUpdateAvailable(PLUGIN, appName) != null)
                updated++;
        }
        if (updated > 0)
            mgr.notifyComplete(null, ngettext("{0} plugin updated", "{0} plugins updated", updated, ctx));
        else
            mgr.notifyComplete(null, Messages.getString("Plugin update check complete", ctx));
    }

    /** this shouldn't throw anything */
    static void startPlugins(RouterContext ctx) {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        Properties props = pluginProperties();
        for (Map.Entry<Object, Object> e : props.entrySet()) {
            String name = (String)e.getKey();
            if (name.startsWith(PREFIX) && name.endsWith(ENABLED)) {
                if (Boolean.parseBoolean((String) e.getValue())) {
                    String app = name.substring(PREFIX.length(), name.lastIndexOf(ENABLED));
                    // plugins could have been started after update
                    if (isPluginRunning(app, ctx))
                        continue;
                    try {
                        if (!startPlugin(ctx, app))
                            log.error("Failed to start plugin: " + app);
                    } catch (Throwable t) {
                        log.error("Failed to start plugin: " + app, t);
                    }
                }
            }
        }
    }

    /**
     *  Deferred deletion of plugins that we failed to delete before.
     *
     *  @since 0.9.13
     */
    private static void deferredDeletePlugins(RouterContext ctx) {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        boolean changed = false;
        Properties props = pluginProperties();
        for (Iterator<Map.Entry<Object, Object>> iter = props.entrySet().iterator(); iter.hasNext(); ) {
            Map.Entry<Object, Object> e = iter.next();
            String name = (String)e.getKey();
            if (name.startsWith(PREFIX) && name.endsWith(ENABLED)) {
                // deferred deletion of a plugin
                if (e.getValue().equals(DELETED)) {
                    String app = name.substring(PREFIX.length(), name.lastIndexOf(ENABLED));
                    // shouldn't happen, this is run early
                    if (isPluginRunning(app, ctx))
                        continue;
                    File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + app);
                    boolean deleted = FileUtil.rmdir(pluginDir, false);
                    if (deleted) {
                        log.logAlways(Log.WARN, "Deferred deletion of " + pluginDir + " successful");
                        iter.remove();
                        changed = true;
                    } else {
                        if (log.shouldLog(Log.WARN))
                            log.warn("Deferred deletion of " + pluginDir + " failed");
                    }
                }
            }
        }
        if (changed)
            storePluginProperties(props);
    }

    /**
     *  @return true on success
     *  @throws Exception just about anything, caller would be wise to catch Throwable
     */
    @SuppressWarnings("deprecation")
    public static boolean startPlugin(RouterContext ctx, String appName) throws Exception {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName);
        String iconfile = null;
        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
            log.error("Cannot start nonexistent plugin: " + appName);
            disablePlugin(appName);
            return false;
        }

        // Do we need to extract an update?
        File pluginUpdate = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName + "/app.xpi2p.zip" );
        if(pluginUpdate.exists()) {
            // Compare the start time of the router with the plugin.
            if(ctx.router().getWhenStarted() > pluginUpdate.lastModified()) {
                if (!FileUtil.extractZip(pluginUpdate, pluginDir)) {
                    pluginUpdate.delete();
                    String foo = "Plugin '" + appName + "' failed to update! File '" + pluginUpdate +"' deleted. You may need to remove and install the plugin again.";
                    log.error(foo);
                    disablePlugin(appName);
                    throw new Exception(foo);
                } else {
                    pluginUpdate.delete();
                    // Need to always log this, and  log.logAlways() did not work for me.
                    System.err.println("INFO: Plugin updated: " + appName);
                }
            } // silently fail to update, because we have not restarted.
        }

        Properties props = pluginProperties(ctx, appName);

        // For the following, we use the exact same translated strings as in PluginUpdateRunner
        // to avoid duplication

        String minVersion = stripHTML(props, "min-i2p-version");
        if (minVersion != null &&
            VersionComparator.comp(CoreVersion.VERSION, minVersion) < 0) {
            String foo = "Plugin " + appName + " requires I2P version " + minVersion + " or higher";
            log.error(foo);
            disablePlugin(appName);
            foo = gettext("This plugin requires I2P version {0} or higher", minVersion, ctx);
            throw new Exception(foo);
        }

        minVersion = stripHTML(props, "min-java-version");
        if (minVersion != null && !SystemVersion.isJava(minVersion)) {
            String foo = "Plugin " + appName + " requires Java version " + minVersion + " or higher";
            log.error(foo);
            disablePlugin(appName);
            foo = gettext("This plugin requires Java version {0} or higher", minVersion, ctx);
            throw new Exception(foo);
        }

        String jVersion = RouterConsoleRunner.jettyVersion();
        minVersion = stripHTML(props, "min-jetty-version");
        if (minVersion != null &&
            VersionComparator.comp(minVersion, jVersion) > 0) {
            String foo = "Plugin " + appName + " requires Jetty version " + minVersion + " or higher";
            log.error(foo);
            disablePlugin(appName);
            foo = gettext("Plugin requires Jetty version {0} or higher", minVersion, ctx);
            throw new Exception(foo);
        }

        String blacklistVersion = jetty9Blacklist.get(appName);
        String curVersion = stripHTML(props, "version");
        if (blacklistVersion != null &&
            VersionComparator.comp(curVersion, blacklistVersion) <= 0) {
            String foo = "Plugin " + appName + " requires Jetty version 8.9999 or lower";
            log.error(foo);
            disablePlugin(appName);
            foo = gettext("Plugin requires Jetty version {0} or lower", "8.9999", ctx);
            throw new Exception(foo);
        }

        if (SystemVersion.isJava9()) {
            blacklistVersion = java9Blacklist.get(appName);
            if (blacklistVersion != null &&
                VersionComparator.comp(curVersion, blacklistVersion) <= 0) {
                String foo = "Plugin " + appName + " requires Jetty version 8.9999 or lower";
                log.error(foo);
                disablePlugin(appName);
                foo = gettext("Plugin requires Java version {0} or lower", "8.9999", ctx);
                throw new Exception(foo);
            }
        }

        String maxVersion = stripHTML(props, "max-jetty-version");
        if (maxVersion != null &&
            VersionComparator.comp(maxVersion, jVersion) < 0) {
            String foo = "Plugin " + appName + " requires Jetty version " + maxVersion + " or lower";
            log.error(foo);
            disablePlugin(appName);
            foo = gettext("Plugin requires Jetty version {0} or lower", maxVersion, ctx);
            throw new Exception(foo);
        }

        if (log.shouldLog(Log.INFO))
            log.info("Starting plugin: " + appName);

        // register themes
        File dir = new File(pluginDir, "console/themes");
        File[] tfiles = dir.listFiles();
        if (tfiles != null) {
            for (int i = 0; i < tfiles.length; i++) {
                String name = tfiles[i].getName();
                if (tfiles[i].isDirectory() && (!Arrays.asList(STANDARD_THEMES).contains(tfiles[i]))) {
                    // deprecated
                    ctx.router().setConfigSetting(CSSHelper.PROP_THEME_PFX + name, tfiles[i].getAbsolutePath());
                    // we don't need to save
                }
            }
        }

        //handle console icons for plugins without web-resources through prop icon-code
        String fullprop = props.getProperty("icon-code");
        if(fullprop != null && fullprop.length() > 1){
            byte[] decoded = Base64.decode(fullprop);
            if(decoded != null) {
                NavHelper.getInstance(ctx).setBinary(appName, decoded);
                iconfile = "/Plugins/pluginicon?plugin=" + appName;
            } else {
                iconfile = "/themes/console/images/plugin.png";
            }
        }

        // load and start things in clients.config
        File clientConfig = new File(pluginDir, "clients.config");
        if (clientConfig.exists()) {
            Properties cprops = new Properties();
            DataHelper.loadProps(cprops, clientConfig);
            List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig);
            runClientApps(ctx, pluginDir, clients, "start");
        }

        // start console webapps in console/webapps
        ContextHandlerCollection server = WebAppStarter.getConsoleServer(ctx);
        if (server != null) {
            File consoleDir = new File(pluginDir, "console");
            Properties wprops = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath());
            File webappDir = new File(consoleDir, "webapps");
            File files[] = webappDir.listFiles(RouterConsoleRunner.WAR_FILTER);
            if (files != null) {
                if(!pluginWars.containsKey(appName))
                    pluginWars.put(appName, new ConcurrentHashSet<String>());
                for (int i = 0; i < files.length; i++) {
                    try {
                        String warName = files[i].getName();
                        warName = warName.substring(0, warName.lastIndexOf(".war"));
                        //log.error("Found webapp: " + warName);
                        // check for duplicates in $I2P
                        if (Arrays.asList(STANDARD_WEBAPPS).contains(warName)) {
                            log.error("Skipping duplicate webapp " + warName + " in plugin " + appName);
                            continue;
                        }
                        String enabled = wprops.getProperty(RouterConsoleRunner.PREFIX + warName + ENABLED);
                        if (! "false".equals(enabled)) {
                            if (log.shouldLog(Log.INFO))
                                log.info("Starting webapp: " + warName);
                            String path = files[i].getCanonicalPath();
                            WebAppStarter.startWebApp(ctx, server, warName, path, appName);
                            pluginWars.get(appName).add(warName);
                        }
                    } catch (IOException ioe) {
                        log.error("Error resolving '" + files[i] + "' in '" + webappDir, ioe);
                    }
                }
                // Check for iconfile in plugin.properties
                String icfile = stripHTML(props, "console-icon");
                if (icfile != null && !icfile.contains("..")) {
                    StringBuilder buf = new StringBuilder(32);
                    String url = stripHTML(props, "consoleLinkURL");
                    if (url != null) {
                        if (url.endsWith(".html") || url.endsWith(".htm") || url.endsWith(".jsp") || url.endsWith("/home")) {
                            int slash = url.lastIndexOf('/');
                            if (slash > 0)
                                url = url.substring(0, slash);
                        }
                    } else {
                        url = appName;
                    }
                    if (!url.startsWith("/"))
                        buf.append('/');
                    buf.append(url);
                    if (!url.endsWith("/") && !icfile.startsWith("/"))
                        buf.append('/');
                    else if (url.endsWith("/") && icfile.startsWith("/"))
                        icfile = icfile.substring(1);
                    buf.append(icfile);
                    iconfile = buf.toString();
                }
            }
        } else {
            log.error("No console web server to start plugins?");
        }

        // add translation jars in console/locale
        // These will not override existing resource bundles since we are adding them
        // later in the classpath.
        File localeDir = new File(pluginDir, "console/locale");
        if (localeDir.exists() && localeDir.isDirectory()) {
            File[] files = localeDir.listFiles(new FileSuffixFilter(".jar"));
            if (files != null) {
                boolean added = false;
                for (int i = 0; i < files.length; i++) {
                    File f = files[i];
                    try {
                        addPath(f.toURI().toURL());
                        log.info("INFO: Adding translation plugin to classpath: " + f);
                        added = true;
                    } catch (ClassCastException e) {
                        log.logAlways(Log.WARN, "Java version: " + System.getProperty("java.version") +
                                                " does not support adding classpath element: " + f +
                                                " for plugin " + appName);
                    } catch (RuntimeException e) {
                        log.error("Plugin " + appName + " bad classpath element: " + f, e);
                    }
                }
                if (added)
                    Translate.clearCache();
            }
        }
        // add summary bar link
        String name = stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx));
        if (name == null)
            name = stripHTML(props, "consoleLinkName");
        String url = stripHTML(props, "consoleLinkURL");
        if (name != null && url != null && name.length() > 0 && url.length() > 0) {
            String tip = stripHTML(props, "consoleLinkTooltip_" + Messages.getLanguage(ctx));
            if (tip == null)
                tip = stripHTML(props, "consoleLinkTooltip");
            NavHelper.getInstance(ctx).registerApp(appName, name, url, tip, iconfile);
        }

        return true;
    }

    /**
     *  @return true on success
     *  @throws Exception just about anything, caller would be wise to catch Throwable
     */
    public static boolean stopPlugin(RouterContext ctx, String appName) throws Exception {
        Server s = RouterConsoleRunner.getConsoleServer(ctx);
        return stopPlugin(ctx, s, appName);
    }

    /**
     *  @return true on success
     *  @throws Exception just about anything, caller would be wise to catch Throwable
     *  @since 0.9.41
     */
    protected static boolean stopPlugin(RouterContext ctx, Server s, String appName) throws Exception {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName);
        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
            log.error("Cannot stop nonexistent plugin: " + appName);
            return false;
        }
        if (log.shouldLog(Log.WARN))
            log.warn("Stopping plugin: " + appName);

        ClientApp client = ctx.clientAppManager().getRegisteredApp(appName);
        if (client != null) {
            try{
                client.shutdown(null);
            }catch(Throwable t){
                if (log.shouldLog(Log.ERROR))
                    log.error("Error stopping client app: " + appName, t);
            }
        } else {
            // stop things in clients.config
            File clientConfig = new File(pluginDir, "clients.config");
            if (clientConfig.exists()) {
                Properties props = new Properties();
                DataHelper.loadProps(props, clientConfig);
                List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig);
                runClientApps(ctx, pluginDir, clients, "stop");
            }
        }

        // stop console webapps in console/webapps
        //ContextHandlerCollection server = WebAppStarter.getConsoleServer();
        //if (server != null) {
        /*
            File consoleDir = new File(pluginDir, "console");
            Properties props = RouterConsoleRunner.webAppProperties(consoleDir.getAbsolutePath());
            File webappDir = new File(consoleDir, "webapps");
            String fileNames[] = webappDir.list(RouterConsoleRunner.WarFilenameFilter.instance());
            if (fileNames != null) {
                for (int i = 0; i < fileNames.length; i++) {
                    String warName = fileNames[i].substring(0, fileNames[i].lastIndexOf(".war"));
                    if (Arrays.asList(STANDARD_WEBAPPS).contains(warName)) {
                        continue;
                    }
                    WebAppStarter.stopWebApp(server, warName);
                }
            }
        */
            if (s != null) {
                Collection<String> wars = pluginWars.get(appName);
                if (wars != null) {
                    for (String warName : wars) {
                        if (log.shouldInfo())
                            log.info("Stopping webapp " + warName + " in plugin " + appName);
                        WebAppStarter.stopWebApp(ctx, s, warName);
                    }
                    wars.clear();
                }
            }
        //}

        // remove summary bar link
        NavHelper.getInstance(ctx).unregisterApp(appName);

        return true;
    }

    /**
     *  @return true on success - caller should call stopPlugin() first
     *  @since public since 0.9.33, was package private
     */
    public static boolean deletePlugin(RouterContext ctx, String appName) throws Exception {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        File pluginDir = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName);
        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
            log.error("Cannot delete nonexistent plugin: " + appName);
            return false;
        }
        // uninstall things in clients.config
        File clientConfig = new File(pluginDir, "clients.config");
        if (clientConfig.exists()) {
            Properties props = new Properties();
            DataHelper.loadProps(props, clientConfig);
            List<ClientAppConfig> clients = ClientAppConfig.getClientApps(clientConfig);
            runClientApps(ctx, pluginDir, clients, "uninstall");
        }

        // unregister themes, and switch to default if we are unregistering the current theme
        File dir = new File(pluginDir, "console/themes");
        File[] tfiles = dir.listFiles();
        if (tfiles != null) {
            String current = ctx.getProperty(CSSHelper.PROP_THEME_NAME);
            Map<String, String> changes = new HashMap<String, String>();
            List<String> removes = new ArrayList<String>();
            for (int i = 0; i < tfiles.length; i++) {
                String name = tfiles[i].getName();
                if (tfiles[i].isDirectory() && (!Arrays.asList(STANDARD_THEMES).contains(tfiles[i]))) {
                    removes.add(CSSHelper.PROP_THEME_PFX + name);
                    if (name.equals(current))
                        changes.put(CSSHelper.PROP_THEME_NAME, CSSHelper.DEFAULT_THEME);
                }
            }
            ctx.router().saveConfig(changes, removes);
        }

        boolean deleted = FileUtil.rmdir(pluginDir, false);
        Properties props = pluginProperties();
        for (Iterator<?> iter = props.keySet().iterator(); iter.hasNext(); ) {
            String name = (String)iter.next();
            if (name.startsWith(PREFIX + appName + '.'))
                iter.remove();
        }
        if (!deleted) {
            // This happens on Windows when there are plugin jars in classpath
            // Mark it as deleted, we will try again after restart
            log.logAlways(Log.WARN, "Deletion of " + pluginDir + " failed, will try again at restart");
            props.setProperty(PREFIX + appName + ENABLED, DELETED);
        }
        storePluginProperties(props);
        return true;
    }

    /** plugin.config */
    public static Properties pluginProperties(I2PAppContext ctx, String appName) {
        File cfgFile = new File(ctx.getConfigDir(), PLUGIN_DIR + '/' + appName + '/' + "plugin.config");
        Properties rv = new Properties();
        try {
            DataHelper.loadProps(rv, cfgFile);
        } catch (IOException ioe) {}
        return rv;
    }

    /**
     *  plugins.config
     *  this auto-adds a property for every dir in the plugin directory
     */
    public static Properties pluginProperties() {
        File dir = I2PAppContext.getGlobalContext().getConfigDir();
        Properties rv = new Properties();
        File cfgFile = new File(dir, CONFIG_FILE);

        try {
            DataHelper.loadProps(rv, cfgFile);
        } catch (IOException ioe) {}

        List<String> names = getAllPlugins();
        for (String name : names) {
            String prop = PREFIX + name + ENABLED;
            if (rv.getProperty(prop) == null)
                rv.setProperty(prop, "true");
        }
        return rv;
    }

    /**
     *  Is the plugin enabled in plugins.config?
     *  Default true
     *
     *  @since 0.8.13
     */
    public static boolean isPluginEnabled(String appName) {
        Properties props = pluginProperties();
        String prop = PREFIX + appName + ENABLED;
        return Boolean.parseBoolean(props.getProperty(prop, "true"));
    }

    /**
     *  Disable in plugins.config
     *
     *  @since 0.8.13
     */
    public static void disablePlugin(String appName) {
        Properties props = pluginProperties();
        String prop = PREFIX + appName + ENABLED;
        if (Boolean.parseBoolean(props.getProperty(prop, "true"))) {
            props.setProperty(prop, "false");
            storePluginProperties(props);
        }
    }

    /**
     *  all installed plugins whether enabled or not,
     *  but does NOT include plugins marked as deleted.
     *  @return non-null, sorted, modifiable
     */
    public static List<String> getPlugins() {
        List<String> rv = getAllPlugins();
        Properties props = pluginProperties();
        for (Iterator<String> iter = rv.iterator(); iter.hasNext(); ) {
            String app = iter.next();
            if (DELETED.equals(props.getProperty(PREFIX + app + ENABLED)))
                iter.remove();
        }
        Collections.sort(rv); // ensure the list is in sorted order.
        return rv;
    }

    /**
     *  all installed plugins whether enabled or not,
     *  DOES include plugins marked as deleted.
     *  @return non-null, unsorted, modifiable
     *  @since 0.9.13
     */
    private static List<String> getAllPlugins() {
        List<String> rv = new ArrayList<String>();
        File pluginDir = new File(I2PAppContext.getGlobalContext().getConfigDir(), PLUGIN_DIR);
        File[] files = pluginDir.listFiles();
        if (files == null)
            return rv;
        for (int i = 0; i < files.length; i++) {
            if (files[i].isDirectory())
                rv.add(files[i].getName());
        }
        return rv;
    }

    /**
     *  The signing keys from all the plugins
     *  @return Map of key to keyname
     *  Last one wins if a dup (installer should prevent dups)
     */
    public static Map<String, String> getPluginKeys(I2PAppContext ctx) {
        Map<String, String> rv = new HashMap<String, String>();
        List<String> names = getPlugins();
        for (String name : names) {
            Properties props = pluginProperties(ctx, name);
            String pubkey = props.getProperty("key");
            String signer = props.getProperty("signer");
            if (pubkey != null && signer != null && pubkey.length() == 172 && signer.length() > 0)
                rv.put(pubkey, signer);
        }
        return rv;
    }

    /**
     *  plugins.config
     */
    public static void storePluginProperties(Properties props) {
        File cfgFile = new File(I2PAppContext.getGlobalContext().getConfigDir(), CONFIG_FILE);
        try {
            DataHelper.storeProps(props, cfgFile);
        } catch (IOException ioe) {}
    }

    /**
     *  @param action "start" or "stop" or "uninstall"
     *  @throws Exception just about anything if an app has a delay less than zero, caller would be wise to catch Throwable
     *  If no apps have a delay less than zero, it shouldn't throw anything
     */
    private static void runClientApps(RouterContext ctx, File pluginDir, List<ClientAppConfig> apps, String action) throws Exception {
        Log log = ctx.logManager().getLog(PluginStarter.class);

        // initialize pluginThreadGroup and _pendingPluginClients
        String pluginName = pluginDir.getName();
        if (!pluginThreadGroups.containsKey(pluginName))
            pluginThreadGroups.put(pluginName, new ThreadGroup(pluginName));
        ThreadGroup pluginThreadGroup = pluginThreadGroups.get(pluginName);
        if (action.equals("start"))
            _pendingPluginClients.put(pluginName, new ConcurrentHashSet<SimpleTimer2.TimedEvent>());

        for(ClientAppConfig app : apps) {
            // If the client is a running ClientApp that we want to stop,
            // bypass all the logic below.
            if (action.equals("stop")) {
                String[] argVal = LoadClientAppsJob.parseArgs(app.args);
                // We must do all the substitution just as when started, so the
                // argument array comparison in getClientApp() works.
                // Do this after parsing so we don't need to worry about quoting
                for (int i = 0; i < argVal.length; i++) {
                    if (argVal[i].indexOf('$') >= 0) {
                        argVal[i] = argVal[i].replace("$I2P", ctx.getBaseDir().getAbsolutePath());
                        argVal[i] = argVal[i].replace("$CONFIG", ctx.getConfigDir().getAbsolutePath());
                        argVal[i] = argVal[i].replace("$PLUGIN", pluginDir.getAbsolutePath());
                        argVal[i] = argVal[i].replace("$OS", SystemVersion.getOS());
                        argVal[i] = argVal[i].replace("$ARCH", SystemVersion.getArch());
                    }
                }
                ClientApp ca = ctx.routerAppManager().getClientApp(app.className, argVal);
                if (ca != null) {
                    // even if (ca.getState() != ClientAppState.RUNNING), we do this, we don't want to fall thru
                    try {
                        ca.shutdown(LoadClientAppsJob.parseArgs(app.stopargs));
                    } catch (Throwable t) {
                        throw new Exception(t);
                    }
                    continue;
                }
            }

            if (action.equals("start") && app.disabled)
                continue;
            String argVal[];
            if (action.equals("start")) {
                // start
                argVal = LoadClientAppsJob.parseArgs(app.args);
            } else {
                String args;
                if (action.equals("stop"))
                    args = app.stopargs;
                else if (action.equals("uninstall"))
                    args = app.uninstallargs;
                else
                    throw new IllegalArgumentException("bad action");
                // args must be present
                if (args == null || args.length() <= 0)
                    continue;
                argVal = LoadClientAppsJob.parseArgs(args);
            }
            // do this after parsing so we don't need to worry about quoting
            for (int i = 0; i < argVal.length; i++) {
                if (argVal[i].indexOf('$') >= 0) {
                    argVal[i] = argVal[i].replace("$I2P", ctx.getBaseDir().getAbsolutePath());
                    argVal[i] = argVal[i].replace("$CONFIG", ctx.getConfigDir().getAbsolutePath());
                    argVal[i] = argVal[i].replace("$PLUGIN", pluginDir.getAbsolutePath());
                    argVal[i] = argVal[i].replace("$OS", SystemVersion.getOS());
                    argVal[i] = argVal[i].replace("$ARCH", SystemVersion.getArch());
                }
            }

            ClassLoader cl = null;
            if (app.classpath != null) {
                String cp = app.classpath;
                if (cp.indexOf('$') >= 0) {
                    cp = cp.replace("$I2P", ctx.getBaseDir().getAbsolutePath());
                    cp = cp.replace("$CONFIG", ctx.getConfigDir().getAbsolutePath());
                    cp = cp.replace("$PLUGIN", pluginDir.getAbsolutePath());
                    cp = cp.replace("$OS", SystemVersion.getOS());
                    cp = cp.replace("$ARCH", SystemVersion.getArch());
                }

                // Old way - add for the whole JVM
                //addToClasspath(cp, app.clientName, log);

                // New way - add only for this client
                // We cache the ClassLoader we start the client with, so
                // we can reuse it for stopping and uninstalling.
                // If we don't, the client won't be able to find its
                // static members.
                String clCacheKey = pluginName + app.className + app.args;
                if (!action.equals("start"))
                    cl = _clCache.get(clCacheKey);
                if (cl == null) {
                    URL[] urls = classpathToURLArray(cp, app.clientName, log);
                    if (urls != null) {
                        cl = new URLClassLoader(urls, ClassLoader.getSystemClassLoader());
                        if (action.equals("start"))
                            _clCache.put(clCacheKey, cl);
                    }
                }
            }

            if (app.delay < 0 && action.equals("start")) {
                // this will throw exceptions
                LoadClientAppsJob.runClientInline(app.className, app.clientName, argVal, log, cl);
            } else if (app.delay == 0 || !action.equals("start")) {
                // quick check, will throw ClassNotFoundException on error
                LoadClientAppsJob.testClient(app.className, cl);
                // run this guy now
                LoadClientAppsJob.runClient(app.className, app.clientName, argVal, ctx, log, pluginThreadGroup, cl);
            } else {
                // If there is some delay, there may be a really good reason for it.
                // Loading a class would be one of them!
                // So we do a quick check first, If it bombs out, we delay and try again.
                // If it bombs after that, then we throw the ClassNotFoundException.
                try {
                    // quick check
                    LoadClientAppsJob.testClient(app.className, cl);
                } catch (ClassNotFoundException ex) {
                    // Try again 1 or 2 seconds later.
                    // This should be enough time. Although it is a lousy hack
                    // it should work for most cases.
                    // Perhaps it may be even better to delay a percentage
                    // if > 1, and reduce the delay time.
                    // Under normal circumstances there will be no delay at all.
                    try {
                        if (app.delay > 1) {
                            Thread.sleep(2000);
                        } else {
                            Thread.sleep(1000);
                        }
                    } catch (InterruptedException ie) {}
                    // quick check, will throw ClassNotFoundException on error
                    LoadClientAppsJob.testClient(app.className, cl);
                }
                // wait before firing it up
                SimpleTimer2.TimedEvent evt = new TrackedDelayedClient(pluginName, ctx.simpleTimer2(), ctx, app.className,
                                                                       app.clientName, argVal, pluginThreadGroup, cl);
                evt.schedule(app.delay);
            }
        }
    }

    /**
     *  Simple override to track whether a plugin's client is delayed and queued
     *  @since 0.9.6
     */
    private static class TrackedDelayedClient extends LoadClientAppsJob.DelayedRunClient {
        private final String _pluginName;

        public TrackedDelayedClient(String pluginName,
                                    SimpleTimer2 pool, RouterContext enclosingContext, String className, String clientName,
                                    String args[], ThreadGroup threadGroup, ClassLoader cl) {
            super(pool, enclosingContext, className, clientName, args, threadGroup, cl);
            _pluginName = pluginName;
            _pendingPluginClients.get(pluginName).add(this);
        }

        @Override
        public boolean cancel() {
            boolean rv = super.cancel();
            _pendingPluginClients.get(_pluginName).remove(this);
            return rv;
        }

        @Override
        public void timeReached() {
            super.timeReached();
            _pendingPluginClients.get(_pluginName).remove(this);
        }
    }

    public static boolean isPluginRunning(String pluginName, RouterContext ctx) {
        Server s = RouterConsoleRunner.getConsoleServer(ctx);
        return isPluginRunning(pluginName, ctx, s);
    }

    /**
     *  @since 0.9.41
     */
    protected static boolean isPluginRunning(String pluginName, RouterContext ctx, Server s) {
        Log log = ctx.logManager().getLog(PluginStarter.class);

        boolean isJobRunning = false;
        Collection<SimpleTimer2.TimedEvent> pending = _pendingPluginClients.get(pluginName);
        if (pending != null && !pending.isEmpty()) {
            // TODO have a pending indication too
            isJobRunning = true;
        }

        boolean isWarRunning = false;
        if (s != null) {
            Collection<String> wars = pluginWars.get(pluginName);
            if (wars != null) {
                for (String warName : wars) {
                    if (WebAppStarter.isWebAppRunning(s, warName)) {
                        isWarRunning = true;
                        break;
                    }
                }
            }
        }

        // load and check for ShellServices.
        boolean isProcessRunning = false;
        ClientApp client = ctx.clientAppManager().getRegisteredApp(pluginName);
        if (client != null) {
            if (log.shouldLog(Log.DEBUG))
                log.debug("Checking state of client " + pluginName + client.getState());
            if (client.getState() == ClientAppState.RUNNING) {
                isProcessRunning = true;
            }
        } else {
            if (log.shouldLog(Log.DEBUG))
                log.debug("No client found for plugin " + pluginName);
        }

        boolean isClientThreadRunning = isClientThreadRunning(pluginName, ctx);
        if (log.shouldLog(Log.DEBUG))
            log.debug("plugin name = <" + pluginName +
            ">; threads running? " + isClientThreadRunning +
            "; webapp running? " + isWarRunning +
            "; jobs running? " + isJobRunning +
            "; process running? " + isProcessRunning);
        return isClientThreadRunning || isWarRunning || isJobRunning || isProcessRunning;
        //
        //if (log.shouldLog(Log.DEBUG))
        //    log.debug("plugin name = <" + pluginName + ">; threads running? " + isClientThreadRunning(pluginName) + "; webapp running? " + WebAppStarter.isWebAppRunning(pluginName) + "; jobs running? " + isJobRunning);
        //return isClientThreadRunning(pluginName) || WebAppStarter.isWebAppRunning(pluginName) || isJobRunning;
        //
    }

    /**
     * Returns <code>true</code> if one or more client threads are running in a given plugin.
     * @param pluginName
     * @return true if running
     */
    private static boolean isClientThreadRunning(String pluginName, RouterContext ctx) {
        ThreadGroup group = pluginThreadGroups.get(pluginName);
        if (group == null)
            return false;
        boolean rv = group.activeCount() > 0;

        // Plugins start before the I2P Site, and will create the static Timer thread
        // in RolloverFileOutputStream, which never stops. Don't count it.
        // Ditto HSQLDB Timer (jwebcache)
        if (rv) {
            Log log = ctx.logManager().getLog(PluginStarter.class);
            Thread[] activeThreads = new Thread[128];
            int count = group.enumerate(activeThreads);
            boolean notRollover = false;
            for (int i = 0; i < count; i++) {
                if (activeThreads[i] != null) {
                    String name = activeThreads[i].getName();
                    if (!"org.eclipse.jetty.util.RolloverFileOutputStream".equals(name) &&
                        !name.startsWith("HSQLDB Timer"))
                        notRollover = true;
                    if (log.shouldLog(Log.DEBUG))
                        log.debug("Found " + activeThreads[i].getState() + " thread " + name + " for " + pluginName + ": " + name);
                }
            }
            rv = notRollover;
        }

        return rv;
    }

    /**
     *  Perhaps there's an easy way to use Thread.setContextClassLoader()
     *  but I don't see how to make it magically get used for everything.
     *  So add this to the whole JVM's classpath.
     */
/******
    private static void addToClasspath(String classpath, String clientName, Log log) {
        StringTokenizer tok = new StringTokenizer(classpath, ",");
        while (tok.hasMoreTokens()) {
            String elem = tok.nextToken().trim();
            File f = new File(elem);
            if (!f.isAbsolute()) {
                log.error("Plugin client " + clientName + " classpath element is not absolute: " + f);
                continue;
            }
            try {
                addPath(f.toURI().toURL());
                if (log.shouldLog(Log.WARN))
                    log.warn("INFO: Adding plugin to classpath: " + f);
            } catch (Exception e) {
                log.error("Plugin client " + clientName + " bad classpath element: " + f, e);
            }
        }
    }
*****/

    /**
     *  @return null if no valid elements
     */
    private static URL[] classpathToURLArray(String classpath, String clientName, Log log) {
        StringTokenizer tok = new StringTokenizer(classpath, ",");
        List<URL> urls = new ArrayList<URL>();
        while (tok.hasMoreTokens()) {
            String elem = tok.nextToken().trim();
            File f = new File(elem);
            if (!f.isAbsolute()) {
                log.error("Plugin client " + clientName + " classpath element is not absolute: " + f);
                continue;
            }
            try {
                urls.add(f.toURI().toURL());
                if (log.shouldLog(Log.WARN))
                    log.warn("INFO: Adding plugin to classpath: " + f);
            } catch (IOException e) {
                log.error("Plugin client " + clientName + " bad classpath element: " + f, e);
            }
        }
        if (urls.isEmpty())
            return null;
        return urls.toArray(new URL[urls.size()]);
    }

    /**
     *  http://jimlife.wordpress.com/2007/12/19/java-adding-new-classpath-at-runtime/
     *
     *  @throws ClassCastException in Java 9
     */
    private static void addPath(URL u) throws Exception {
        URLClassLoader urlClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
        Class<URLClassLoader> urlClass = URLClassLoader.class;
        Method method = urlClass.getDeclaredMethod("addURL", URL.class);
        method.setAccessible(true);
        method.invoke(urlClassLoader, new Object[]{u});
    }

    /**
     *  Like in DataHelper but doesn't convert null to ""
     *  There's a lot worse things a plugin could do but...
     *  @since moved from ConfigClientsHelper in 0.9.33
     */
    public static String stripHTML(Properties props, String key) {
        String orig = props.getProperty(key);
        if (orig == null) return null;
        String t1 = orig.replace('<', ' ');
        String rv = t1.replace('>', ' ');
        return rv;
    }

    /**
     * translate a string
     * @since 0.9.30
     */
    private static String gettext(String s, Object o, I2PAppContext ctx) {
        return Messages.getString(s, o, ctx);
    }

    /** translate a string */
    private static String ngettext(String s, String p, int n, I2PAppContext ctx) {
        return Messages.getString(n, s, p, ctx);
    }
}
