/*
 * Copyright (c) 2007, 2018, Oracle and/or its affiliates. All rights reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle in the LICENSE file that accompanied this code.
 *
 * This code 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
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package org.graalvm.visualvm.modules.jconsole;

import com.sun.tools.jconsole.JConsoleContext;
import static com.sun.tools.jconsole.JConsoleContext.*;
import com.sun.tools.jconsole.JConsoleContext.ConnectionState;
import com.sun.tools.jconsole.JConsolePlugin;
import org.graalvm.visualvm.application.Application;
import org.graalvm.visualvm.core.ui.components.Spacer;
import org.graalvm.visualvm.modules.jconsole.options.JConsoleSettings;
import org.graalvm.visualvm.tools.jmx.JmxModel;
import org.graalvm.visualvm.tools.jmx.JmxModelFactory;
import java.awt.BorderLayout;
import java.awt.Font;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.ServiceConfigurationError;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.logging.Logger;
import javax.management.MBeanServerConnection;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTabbedPane;
import javax.swing.JTextArea;
import javax.swing.SwingWorker;
import javax.swing.Timer;
import javax.swing.event.SwingPropertyChangeSupport;
import org.netbeans.api.options.OptionsDisplayer;
import org.openide.awt.Mnemonics;
import org.openide.util.NbBundle;
import org.openide.util.RequestProcessor;

class JConsolePluginWrapper {

    private static final Logger LOGGER = Logger.getLogger(JConsolePluginWrapper.class.getName());
    private ServiceLoader<JConsolePlugin> pluginService;
    private JComponent jconsoleView;
    private VMPanel vmPanel;

    JConsolePluginWrapper(Application application) {
        JmxModel jmxModel = JmxModelFactory.getJmxModelFor(application);
        if (jmxModel == null || jmxModel.getConnectionState() != JmxModel.ConnectionState.CONNECTED) {
            JTextArea textArea = new JTextArea();
            textArea.setBorder(BorderFactory.createEmptyBorder(25, 9, 9, 9));
            textArea.setEditable(false);
            textArea.setLineWrap(true);
            textArea.setWrapStyleWord(true);
            textArea.setText(NbBundle.getMessage(JConsolePluginWrapper.class, "JMX_Not_Available")); // NOI18N
            jconsoleView = textArea;
        } else {
            boolean availablePlugins = getPlugins().iterator().hasNext();
            if (availablePlugins) {
                vmPanel = new VMPanel(application, this, new ProxyClient(jmxModel));
                vmPanel.connect();
                JPanel panel = new JPanel(new BorderLayout());
                panel.setOpaque(false);
                panel.add(new JLabel(" "), BorderLayout.NORTH); // NOI18N
                panel.add(vmPanel, BorderLayout.CENTER);
                jconsoleView = panel;
            } else {
                GridBagConstraints c;

                JPanel hintPanel = new JPanel(new GridBagLayout());
                hintPanel.setOpaque(false);
                hintPanel.setBorder(BorderFactory.createEmptyBorder(25, 9, 9, 9));

                JLabel hintLabel = new JLabel(NbBundle.getMessage(
                        JConsolePluginWrapper.class, "NoPluginInstalled")); // NOI18N
                hintLabel.setFont(hintLabel.getFont().deriveFont(Font.BOLD));
                c = new GridBagConstraints();
                c.gridy = 0;
                c.anchor = GridBagConstraints.WEST;
                c.fill = GridBagConstraints.NONE;
                c.insets = new Insets(0, 0, 0, 0);
                hintPanel.add(hintLabel, c);

                JTextArea hintArea = new JTextArea();
                hintArea.setEnabled(false);
                hintArea.setEditable(false);
                hintArea.setLineWrap(true);
                hintArea.setWrapStyleWord(true);
                hintArea.setDisabledTextColor(hintArea.getForeground());
                hintArea.setOpaque(false);
                hintArea.setText(NbBundle.getMessage(
                        JConsolePluginWrapper.class, "InstallPluginHint")); // NOI18N
                c = new GridBagConstraints();
                c.gridy = 1;
                c.weightx = 1;
                c.anchor = GridBagConstraints.WEST;
                c.fill = GridBagConstraints.HORIZONTAL;
                c.insets = new Insets(5, 0, 0, 0);
                hintPanel.add(hintArea, c);

                JButton optionsButton = new JButton() {
                    protected void fireActionPerformed(ActionEvent event) {
                        OptionsDisplayer.getDefault().open("JConsoleOptions"); // NOI18N
                    }
                };
                Mnemonics.setLocalizedText(optionsButton, NbBundle.getMessage(
                        JConsolePluginWrapper.class, "ConfigurePlugins")); // NOI18N
                c = new GridBagConstraints();
                c.gridy = 2;
                c.anchor = GridBagConstraints.EAST;
                c.fill = GridBagConstraints.NONE;
                c.insets = new Insets(10, 0, 0, 0);
                hintPanel.add(optionsButton, c);

                c = new GridBagConstraints();
                c.gridy = 3;
                c.weighty = 1;
                c.anchor = GridBagConstraints.NORTHWEST;
                c.fill = GridBagConstraints.BOTH;
                c.gridwidth = GridBagConstraints.REMAINDER;
                hintPanel.add(Spacer.create(), c);

                jconsoleView = hintPanel;
            }
        }
    }

    JComponent getView() {
        return jconsoleView;
    }
    void releasePlugins() {
        if (vmPanel != null) {
            vmPanel.disconnect();
        }
    }

    // Return a list of newly instantiated JConsolePlugin objects
    synchronized List<JConsolePlugin> getPlugins() {
        if (pluginService == null) {
            String pluginPath = JConsoleSettings.getDefault().getPluginsPath();
            // First time loading and initializing the plugins
            initPluginService(pluginPath == null ? "" : pluginPath); // NOI18N
        } else {
            // Reload the plugin so that new instances will be created
            pluginService.reload();
        }
        List<JConsolePlugin> plugins = new ArrayList<JConsolePlugin>();
        for (JConsolePlugin p : pluginService) {
            plugins.add(p);
        }
        return plugins;
    }

    private void initPluginService(String pluginPath) {
        if (pluginPath.length() > 0) {
            try {
                ClassLoader pluginCL = new URLClassLoader(
                        pathToURLs(pluginPath),
                        JConsolePluginWrapper.class.getClassLoader());
                ServiceLoader<JConsolePlugin> plugins =
                        ServiceLoader.load(JConsolePlugin.class, pluginCL);
                // Validate all plugins
                for (JConsolePlugin p : plugins) {
                    LOGGER.finer("JConsole plugin " + p.getClass().getName() + " loaded."); // NOI18N
                }
                pluginService = plugins;
            } catch (ServiceConfigurationError e) {
                // Error occurs during initialization of plugin
                LOGGER.warning("Fail to load JConsole plugin: " + e.getMessage()); // NOI18N
                LOGGER.throwing(JConsolePluginWrapper.class.getName(), "initPluginService", e); // NOI18N
            } catch (MalformedURLException e) {
                LOGGER.warning("Invalid JConsole plugin path: " + e.getMessage()); // NOI18N
                LOGGER.throwing(JConsolePluginWrapper.class.getName(), "initPluginService", e); // NOI18N
            }
        }
        if (pluginService == null) {
            initEmptyPlugin();
        }
    }

    private void initEmptyPlugin() {
        ClassLoader pluginCL = new URLClassLoader(new URL[0], JConsolePluginWrapper.class.getClassLoader());
        pluginService = ServiceLoader.load(JConsolePlugin.class, pluginCL);
    }

    /**
     * Utility method for converting a search path string to an array
     * of directory and JAR file URLs.
     *
     * @param path the search path string
     * @return the resulting array of directory and JAR file URLs
     */
    private static URL[] pathToURLs(String path) throws MalformedURLException {
        String[] names = path.split(File.pathSeparator);
        URL[] urls = new URL[names.length + 1];
        urls[0] = JConsolePluginWrapper.class.getProtectionDomain().getCodeSource().getLocation();
        int count = 1;
        for (String f : names) {
            URL url = fileToURL(new File(f));
            urls[count++] = url;
        }
        return urls;
    }

    /**
     * Returns the directory or JAR file URL corresponding to the specified
     * local file name.
     *
     * @param file the File object
     * @return the resulting directory or JAR file URL, or null if unknown
     */
    private static URL fileToURL(File file) throws MalformedURLException {
        String name;
        try {
            name = file.getCanonicalPath();
        } catch (IOException e) {
            name = file.getAbsolutePath();
        }
        name = name.replace(File.separatorChar, '/');
        if (!name.startsWith("/")) { // NOI18N
            name = "/" + name; // NOI18N
        }
        // If the file does not exist, then assume that it's a directory
        if (!file.isFile()) {
            name = name + "/"; // NOI18N
        }
        return new URL("file", "", name); // NOI18N
    }

    class ProxyClient implements JConsoleContext, PropertyChangeListener {

        private ConnectionState connectionState = ConnectionState.DISCONNECTED;

        // The SwingPropertyChangeSupport will fire events on the EDT
        private SwingPropertyChangeSupport propertyChangeSupport =
                new SwingPropertyChangeSupport(this, true);
        private volatile boolean isDead = true;
        private JmxModel jmxModel = null;
        private MBeanServerConnection server = null;

        ProxyClient(JmxModel jmxModel) {
            this.jmxModel = jmxModel;
        }

        private void setConnectionState(ConnectionState state) {
            ConnectionState oldState = this.connectionState;
            this.connectionState = state;
            propertyChangeSupport.firePropertyChange(CONNECTION_STATE_PROPERTY,
                    oldState, state);
        }

        public ConnectionState getConnectionState() {
            return this.connectionState;
        }

        void connect() {
            setConnectionState(ConnectionState.CONNECTING);
            try {
                tryConnect();
                setConnectionState(ConnectionState.CONNECTED);
            } catch (Exception e) {
                e.printStackTrace();
                setConnectionState(ConnectionState.DISCONNECTED);
            }
        }

        private void tryConnect() throws IOException {
            jmxModel.addPropertyChangeListener(this);
            this.server = jmxModel.getMBeanServerConnection();
            this.isDead = false;
        }

        public MBeanServerConnection getMBeanServerConnection() {
            return server;
        }

        synchronized void disconnect() {
            jmxModel.removePropertyChangeListener(this);
            // Set connection state to DISCONNECTED
            if (!isDead) {
                isDead = true;
                setConnectionState(ConnectionState.DISCONNECTED);
            }
        }

        boolean isDead() {
            return isDead;
        }

        boolean isConnected() {
            return !isDead();
        }

        public void addPropertyChangeListener(PropertyChangeListener listener) {
            propertyChangeSupport.addPropertyChangeListener(listener);
        }

        public void removePropertyChangeListener(PropertyChangeListener listener) {
            propertyChangeSupport.removePropertyChangeListener(listener);
        }

        public void propertyChange(PropertyChangeEvent evt) {
            String prop = evt.getPropertyName();
            if (CONNECTION_STATE_PROPERTY.equals(prop)) {
                org.graalvm.visualvm.tools.jmx.JmxModel.ConnectionState newState = (org.graalvm.visualvm.tools.jmx.JmxModel.ConnectionState) evt.getNewValue();
                setConnectionState(ConnectionState.valueOf(newState.name()));
            }
        }
    }

    class VMPanel extends JTabbedPane implements PropertyChangeListener {

        private Application application;
        private ProxyClient proxyClient;
        private Timer timer;
        private int updateInterval = JConsoleSettings.getDefault().getPolling() * 1000;
        private boolean wasConnected = false;

        // Each VMPanel has its own instance of the JConsolePlugin.
        // A map of JConsolePlugin to the previous SwingWorker.
        private Map<JConsolePlugin, SwingWorker<?, ?>> plugins = null;
        private boolean pluginTabsAdded = false;

        VMPanel(Application application, JConsolePluginWrapper wrapper, ProxyClient proxyClient) {
            this.application = application;
            this.proxyClient = proxyClient;
            plugins = new LinkedHashMap<JConsolePlugin, SwingWorker<?, ?>>();
            for (JConsolePlugin p : wrapper.getPlugins()) {
                p.setContext(proxyClient);
                plugins.put(p, null);
            }
            // Start listening to connection state events
            //
            proxyClient.addPropertyChangeListener(this);
        }

        boolean isConnected() {
            return proxyClient.isConnected();
        }

        // Call on EDT
        void connect() {
            if (isConnected()) {
                // Create plugin tabs if not done
                createPluginTabs();
                // Start/Restart update timer on connect/reconnect
                startUpdateTimer();
            } else {
                proxyClient.connect();
            }
        }

        // Call on EDT
        void disconnect() {
            // Disconnect
            proxyClient.disconnect();
            // Dispose JConsole plugins
            disposePlugins(plugins.keySet());
            // Cancel pending update tasks
            //
            if (timer != null) {
                timer.stop();
            }
            // Stop listening to connection state events
            //
            proxyClient.removePropertyChangeListener(this);
        }

        // Called on EDT
        public void propertyChange(PropertyChangeEvent ev) {
            String prop = ev.getPropertyName();
            if (CONNECTION_STATE_PROPERTY.equals(prop)) {
                ConnectionState newState = (ConnectionState) ev.getNewValue();
                switch (newState) {
                    case CONNECTED:
                        // Create tabs if not done
                        createPluginTabs();
                        repaint();
                        // Start/Restart update timer on connect/reconnect
                        startUpdateTimer();
                        break;
                    case DISCONNECTED:
                        disconnect();
                        break;
                }
            }
        }

        private void startUpdateTimer() {
            if (timer != null) {
                timer.stop();
            }
            timer = new Timer(updateInterval, new ActionListener() {
                public void actionPerformed(ActionEvent e) {
                    RequestProcessor.getDefault().post(new Runnable() {
                        public void run() {
                            update();
                        }
                    });
                }
            });
            timer.setCoalesce(true);
            timer.setInitialDelay(0);
            timer.start();
        }

        // Note: This method is called on a TimerTask thread. Any GUI manipulation
        // must be performed with invokeLater() or invokeAndWait().
        private Object lockObject = new Object();

        private void update() {
            synchronized (lockObject) {
                if (!isConnected()) {
                    if (wasConnected) {
                        disconnect();
                    }
                    wasConnected = false;
                    return;
                } else {
                    wasConnected = true;
                }
                // Plugin GUI update
                for (JConsolePlugin p : plugins.keySet()) {
                    SwingWorker<?, ?> sw = p.newSwingWorker();
                    SwingWorker<?, ?> prevSW = plugins.get(p);
                    // Schedule SwingWorker to run only if the previous
                    // SwingWorker has finished its task and it hasn't started.
                    if (prevSW == null || prevSW.isDone()) {
                        if (sw == null || sw.getState() == SwingWorker.StateValue.PENDING) {
                            plugins.put(p, sw);
                            if (sw != null) {
                                RequestProcessor.getDefault().post(sw);
                            }
                        }
                    }
                }
            }
        }

        private void createPluginTabs() {
            // Add plugin tabs if not done
            if (!pluginTabsAdded) {
                Set<JConsolePlugin> failedPlugins = new HashSet<JConsolePlugin>();
                for (JConsolePlugin p : plugins.keySet()) {
                    try {
                        Map<String, JPanel> tabs = p.getTabs();
                        for (Map.Entry<String, JPanel> e : tabs.entrySet()) {
                            addTab(e.getKey(), e.getValue());
                        }
                    } catch (Throwable t) {
                        // Error occurs during plugin tab creation.
                        failedPlugins.add(p);
                        LOGGER.warning("JConsole plugin " + p.getClass().getName() + " removed: Failed to create JConsole plugin tabs."); // NOI18N
                        LOGGER.throwing(VMPanel.class.getName(), "createPluginTabs", t); // NOI18N
                    }
                }
                // Remove plugins that failed to return the plugin tabs
                for (JConsolePlugin p : failedPlugins) {
                    plugins.remove(p);
                }
                disposePlugins(failedPlugins);
                pluginTabsAdded = true;
            }
        }

        private void disposePlugins(Set<JConsolePlugin> pluginSet) {
            for (JConsolePlugin p : pluginSet) {
                try {
                    p.dispose();
                } catch (Throwable t) {
                    // Best effort, ignore if plugin fails to cleanup itself.
                }
            }
        }
    }
}
