package net.i2p.router.web;

import java.io.File;
import java.io.IOException;
import java.lang.ClassLoader;
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.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 net.i2p.I2PAppContext;
import net.i2p.data.DataHelper;
import net.i2p.router.Job;
import net.i2p.router.RouterContext;
import net.i2p.router.startup.ClientAppConfig;
import net.i2p.router.startup.LoadClientAppsJob;
import net.i2p.util.ConcurrentHashSet;
import net.i2p.util.FileUtil;
import net.i2p.util.Log;
import net.i2p.util.Translate;

import org.mortbay.jetty.Server;


/**
 *  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;
    static final String PREFIX = "plugin.";
    static final String ENABLED = ".startOnLoad";
    private static final String[] STANDARD_WEBAPPS = { "i2psnark", "i2ptunnel", "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<Job>> pluginJobs = new ConcurrentHashMap<String, Collection<Job>>();
    private static Map<String, ClassLoader> _clCache = new ConcurrentHashMap();
    private static Map<String, Collection<String>> pluginWars = new ConcurrentHashMap<String, Collection<String>>();

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

    static boolean pluginsEnabled(I2PAppContext ctx) {
         return Boolean.valueOf(ctx.getProperty("router.enablePlugins", "true")).booleanValue();
    }

    public void run() {
        startPlugins(_context);
    }

    /** this shouldn't throw anything */
    static void startPlugins(RouterContext ctx) {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        Properties props = pluginProperties();
        for (Iterator iter = props.keySet().iterator(); iter.hasNext(); ) {
            String name = (String)iter.next();
            if (name.startsWith(PREFIX) && name.endsWith(ENABLED)) {
                if (Boolean.valueOf(props.getProperty(name)).booleanValue()) {
                    String app = name.substring(PREFIX.length(), name.lastIndexOf(ENABLED));
                    try {
                        if (!startPlugin(ctx, app))
                            log.error("Failed to start plugin: " + app);
                    } catch (Throwable e) {
                        log.error("Failed to start plugin: " + app, e);
                    }
                }
            }
        }
    }

    /**
     *  @return true on success
     *  @throws just about anything, caller would be wise to catch Throwable
     */
    static boolean startPlugin(RouterContext ctx, String appName) throws Exception {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        File pluginDir = new File(ctx.getConfigDir(), PluginUpdateHandler.PLUGIN_DIR + '/' + appName);
        if ((!pluginDir.exists()) || (!pluginDir.isDirectory())) {
            log.error("Cannot start nonexistent plugin: " + appName);
            return false;
        }
        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])))
                    ctx.router().setConfigSetting(ConfigUIHelper.PROP_THEME_PFX + name, tfiles[i].getAbsolutePath());
                    // we don't need to save
            }
        }

        // load and start 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, "start");
        }

        // start console webapps in console/webapps
        Server 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) {
                if(!pluginWars.containsKey(appName))
                    pluginWars.put(appName, new ConcurrentHashSet<String>());
                for (int i = 0; i < fileNames.length; i++) {
                    try {
                        String warName = fileNames[i].substring(0, fileNames[i].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 = props.getProperty(RouterConsoleRunner.PREFIX + warName + ENABLED);
                        if (! "false".equals(enabled)) {
                            if (log.shouldLog(Log.INFO))
                                log.info("Starting webapp: " + warName);
                            String path = new File(webappDir, fileNames[i]).getCanonicalPath();
                            WebAppStarter.startWebApp(ctx, server, warName, path);
                            pluginWars.get(appName).add(warName);
                        }
                    } catch (IOException ioe) {
                        log.error("Error resolving '" + fileNames[i] + "' in '" + webappDir, ioe);
                    }
                }
            }
        } 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();
            if (files != null) {
                boolean added = false;
                for (int i = 0; i < files.length; i++) {
                    File f = files[i];
                    if (f.getName().endsWith(".jar")) {
                        try {
                            addPath(f.toURI().toURL());
                            log.error("INFO: Adding translation plugin to classpath: " + f);
                            added = true;
                        } catch (Exception e) {
                            log.error("Plugin " + appName + " bad classpath element: " + f, e);
                        }
                    }
                }
                if (added)
                    Translate.clearCache();
            }
        }

        // add summary bar link
        Properties props = pluginProperties(ctx, appName);
        String name = ConfigClientsHelper.stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx));
        if (name == null)
            name = ConfigClientsHelper.stripHTML(props, "consoleLinkName");
        String url = ConfigClientsHelper.stripHTML(props, "consoleLinkURL");
        if (name != null && url != null && name.length() > 0 && url.length() > 0) {
            String tip = ConfigClientsHelper.stripHTML(props, "consoleLinkTooltip_" + Messages.getLanguage(ctx));
            if (tip == null)
                tip = ConfigClientsHelper.stripHTML(props, "consoleLinkTooltip");
            if (tip != null)
                NavHelper.registerApp(name, url, tip);
            else
                NavHelper.registerApp(name, url);
        }

        return true;
    }

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

        // 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
        Server 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(pluginWars.containsKey(appName)) {
                Iterator <String> wars = pluginWars.get(appName).iterator();
                while (wars.hasNext()) {
                    String warName = wars.next();
                    WebAppStarter.stopWebApp(server, warName);
                }
                pluginWars.get(appName).clear();
            }
        }

        // remove summary bar link
        Properties props = pluginProperties(ctx, appName);
        String name = ConfigClientsHelper.stripHTML(props, "consoleLinkName_" + Messages.getLanguage(ctx));
        if (name == null)
            name = ConfigClientsHelper.stripHTML(props, "consoleLinkName");
        if (name != null && name.length() > 0)
            NavHelper.unregisterApp(name);

        if (log.shouldLog(Log.WARN))
            log.warn("Stopping plugin: " + appName);
        return true;
    }

    /** @return true on success - caller should call stopPlugin() first */
    static boolean deletePlugin(RouterContext ctx, String appName) throws Exception {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        File pluginDir = new File(ctx.getConfigDir(), PluginUpdateHandler.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);
            for (int i = 0; i < tfiles.length; i++) {
                String name = tfiles[i].getName();
                if (tfiles[i].isDirectory() && (!Arrays.asList(STANDARD_THEMES).contains(tfiles[i]))) {
                    ctx.router().removeConfigSetting(ConfigUIHelper.PROP_THEME_PFX + name);
                    if (name.equals(current))
                        ctx.router().setConfigSetting(CSSHelper.PROP_THEME_NAME, CSSHelper.DEFAULT_THEME);
                }
            }
            ctx.router().saveConfig();
        }

        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();
        }
        storePluginProperties(props);
        return true;
    }

    /** plugin.config */
    public static Properties pluginProperties(I2PAppContext ctx, String appName) {
        File cfgFile = new File(ctx.getConfigDir(), PluginUpdateHandler.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 propery 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, "plugins.config");
        
        try {
            DataHelper.loadProps(rv, cfgFile);
        } catch (IOException ioe) {}

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

    /**
     *  all installed plugins whether enabled or not
     */
    public static List<String> getPlugins() {
        List<String> rv = new ArrayList();
        File pluginDir = new File(I2PAppContext.getGlobalContext().getConfigDir(), PluginUpdateHandler.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();
        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(), "plugins.config");
        try {
            DataHelper.storeProps(props, cfgFile);
        } catch (IOException ioe) {}
    }

    /**
     *  @param action "start" or "stop" or "uninstall"
     *  @throws 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 pluginJobs
        String pluginName = pluginDir.getName();
        if (!pluginThreadGroups.containsKey(pluginName))
            pluginThreadGroups.put(pluginName, new ThreadGroup(pluginName));
        ThreadGroup pluginThreadGroup = pluginThreadGroups.get(pluginName);
        if (action.equals("start"))
            pluginJobs.put(pluginName, new ConcurrentHashSet<Job>());
        
        for(ClientAppConfig app : apps) {
            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());
                }
            }

            ClassLoader cl = null;
            if (app.classpath != null) {
                String cp = new String(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());
                }

                // 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, 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.
                   if(app.delay > 1) {
                       Thread.sleep(2000);
                   } else {
                       Thread.sleep(1000);
                   }
               }
               // quick check, will throw ClassNotFoundException on error
               LoadClientAppsJob.testClient(app.className, cl);
               // wait before firing it up
               Job job = new LoadClientAppsJob.DelayedRunClient(ctx, app.className, app.clientName, argVal, app.delay, pluginThreadGroup, cl);
               ctx.jobQueue().addJob(job);
               pluginJobs.get(pluginName).add(job);
            }
        }
    }

    public static boolean isPluginRunning(String pluginName, RouterContext ctx) {
        Log log = ctx.logManager().getLog(PluginStarter.class);
        
        boolean isJobRunning = false;
        if (pluginJobs.containsKey(pluginName))
            for (Job job: pluginJobs.get(pluginName))
                if (ctx.jobQueue().isJobActive(job)) {
                    isJobRunning = true;
                    break;
                }
        boolean isWarRunning = false;
        if(pluginWars.containsKey(pluginName)) {
            Iterator <String> it = pluginWars.get(pluginName).iterator();
            while(it.hasNext() && !isWarRunning) {
                String warName = it.next();
                if(WebAppStarter.isWebAppRunning(warName)) {
                    isWarRunning = true;
                }
            }
        }

        if (log.shouldLog(Log.DEBUG))
            log.debug("plugin name = <" + pluginName + ">; threads running? " + isClientThreadRunning(pluginName) + "; webapp runing? " + isWarRunning + "; jobs running? " + isJobRunning);
        return isClientThreadRunning(pluginName) || isWarRunning || isJobRunning;
        //
        //if (log.shouldLog(Log.DEBUG))
        //    log.debug("plugin name = <" + pluginName + ">; threads running? " + isClientThreadRunning(pluginName) + "; webapp runing? " + 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
     */
    private static boolean isClientThreadRunning(String pluginName) {
        ThreadGroup group = pluginThreadGroups.get(pluginName);
        if (group == null)
            return false;
        
        Thread[] activeThreads = new Thread[1];
        group.enumerate(activeThreads);
        return activeThreads[0] != null;
    }
    
    /**
     *  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();
        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 (Exception 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/
     */
    private static void addPath(URL u) throws Exception {
        URLClassLoader urlClassLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();
        Class urlClass = URLClassLoader.class;
        Method method = urlClass.getDeclaredMethod("addURL", new Class[]{URL.class});
        method.setAccessible(true);
        method.invoke(urlClassLoader, new Object[]{u});
    }
}
