/**
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.activemq.broker.jmx;

import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.rmi.NoSuchObjectException;
import java.rmi.Remote;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;
import java.rmi.server.UnicastRemoteObject;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import javax.management.Attribute;
import javax.management.InstanceNotFoundException;
import javax.management.JMException;
import javax.management.MBeanServer;
import javax.management.MBeanServerFactory;
import javax.management.MBeanServerInvocationHandler;
import javax.management.MalformedObjectNameException;
import javax.management.ObjectInstance;
import javax.management.ObjectName;
import javax.management.QueryExp;
import javax.management.remote.JMXConnectorServer;
import javax.management.remote.JMXServiceURL;
import javax.management.remote.rmi.RMIConnectorServer;
import javax.management.remote.rmi.RMIJRMPServerImpl;

import org.apache.activemq.Service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import static java.lang.ClassLoader.getSystemClassLoader;

/**
 * An abstraction over JMX MBean registration
 *
 * @org.apache.xbean.XBean
 *
 */
public class ManagementContext implements Service {

    /**
     * Default activemq domain
     */
    public static final String DEFAULT_DOMAIN = "org.apache.activemq";

    /**
     * Default registry lookup name
     */
    public static final String DEFAULT_LOOKUP_NAME = "jmxrmi";

    static {
        String option = Boolean.FALSE.toString();
        try {
            option = System.getProperty("org.apache.activemq.broker.jmx.createConnector", "false");
        } catch (Exception ex) {
            // no-op
        }

        DEFAULT_CREATE_CONNECTOR = Boolean.parseBoolean(option);
    }

    public static final boolean DEFAULT_CREATE_CONNECTOR;

    private static final Logger LOG = LoggerFactory.getLogger(ManagementContext.class);
    private MBeanServer beanServer;
    private String jmxDomainName = DEFAULT_DOMAIN;
    private boolean useMBeanServer = true;
    private boolean createMBeanServer = true;
    private boolean locallyCreateMBeanServer;
    private boolean createConnector = DEFAULT_CREATE_CONNECTOR;
    private boolean findTigerMbeanServer = true;
    private String connectorHost = "localhost";
    private int connectorPort = 1099;
    private Map<String, ?> environment;
    private int rmiServerPort;
    private String connectorPath = "/jmxrmi";
    private String lookupName = DEFAULT_LOOKUP_NAME;
    private final AtomicBoolean started = new AtomicBoolean(false);
    private final CountDownLatch connectorStarted = new CountDownLatch(1);
    private JMXConnectorServer connectorServer;
    private ObjectName namingServiceObjectName;
    private Registry registry;
    private final Map<ObjectName, ObjectName> registeredMBeanNames = new ConcurrentHashMap<>();
    private boolean allowRemoteAddressInMBeanNames = true;
    private String brokerName;
    private String suppressMBean;
    private List<ObjectName> suppressMBeanList;
    private Remote serverStub;
    private RMIJRMPServerImpl server;

    public ManagementContext() {
        this(null);
    }

    public ManagementContext(MBeanServer server) {
        this.beanServer = server;
    }

    @Override
    public void start() throws Exception {
        // lets force the MBeanServer to be created if needed
        if (started.compareAndSet(false, true)) {

            populateMBeanSuppressionMap();

            // fallback and use localhost
            if (connectorHost == null) {
                connectorHost = "localhost";
            }

            // force MBean server to be looked up, so we have it
            getMBeanServer();

            if (connectorServer != null) {
                try {
                    if (getMBeanServer().isRegistered(namingServiceObjectName)) {
                        LOG.debug("Invoking start on MBean: {}", namingServiceObjectName);
                        getMBeanServer().invoke(namingServiceObjectName, "start", null, null);
                    }
                } catch (Throwable t) {
                    LOG.debug("Error invoking start on MBean {}. This exception is ignored.", namingServiceObjectName, t);
                }

                Thread t = new Thread("JMX connector") {
                    @Override
                    public void run() {
                        // ensure we use MDC logging with the broker name, so people can see the logs if MDC was in use
                        if (brokerName != null) {
                            MDC.put("activemq.broker", brokerName);
                        }
                        try {
                            if (started.get() && server != null) {
                                LOG.debug("Starting JMXConnectorServer...");
                                try {
                                    // need to remove MDC as we must not inherit MDC in child threads causing leaks
                                    MDC.remove("activemq.broker");
                                    connectorServer.start();
                                    serverStub = server.toStub();
                                } finally {
                                    if (brokerName != null) {
                                        MDC.put("activemq.broker", brokerName);
                                    }
                                    connectorStarted.countDown();
                                }
                                LOG.info("JMX consoles can connect to {}", connectorServer.getAddress());
                            }
                        } catch (IOException e) {
                            LOG.warn("Failed to start JMX connector {}. Will restart management to re-create JMX connector, trying to remedy this issue.", e.getMessage());
                            LOG.debug("Reason for failed JMX connector start", e);
                        } finally {
                            MDC.remove("activemq.broker");
                        }
                    }
                };
                t.setDaemon(true);
                t.start();
            }
        }
    }

    private void populateMBeanSuppressionMap() throws Exception {
        if (suppressMBean != null) {
            suppressMBeanList = new LinkedList<>();
            for (String pair : suppressMBean.split(",")) {
                suppressMBeanList.add(new ObjectName(jmxDomainName + ":*," + pair));
            }
        }
    }

    @Override
    public void stop() throws Exception {
        if (started.compareAndSet(true, false)) {
            MBeanServer mbeanServer = getMBeanServer();

            // unregister the mbeans we have registered
            if (mbeanServer != null) {
                for (Map.Entry<ObjectName, ObjectName> entry : registeredMBeanNames.entrySet()) {
                    final ObjectName actualName = entry.getValue();
                    if (actualName != null && beanServer.isRegistered(actualName)) {
                        try {
                            LOG.debug("Unregistering MBean {}", actualName);
                            mbeanServer.unregisterMBean(actualName);
                        } catch (javax.management.InstanceNotFoundException e) {
                            // Ignore - the MBean was already unregistered (likely by advisory cleanup)
                            // This is a benign race condition during shutdown
                            LOG.trace("MBean already unregistered: {}", actualName);
                        }
                    }
                }
            }
            registeredMBeanNames.clear();

            JMXConnectorServer server = connectorServer;
            connectorServer = null;
            if (server != null) {
                try {
                    if (connectorStarted.await(10, TimeUnit.SECONDS)) {
                        LOG.debug("Stopping jmx connector");
                        server.stop();
                    }
                } catch (IOException e) {
                    LOG.warn("Failed to stop jmx connector: {}", e.getMessage());
                }
                // stop naming service MBean
                try {
                    if (namingServiceObjectName != null && getMBeanServer().isRegistered(namingServiceObjectName)) {
                        LOG.debug("Stopping MBean {}", namingServiceObjectName);
                        getMBeanServer().invoke(namingServiceObjectName, "stop", null, null);
                        LOG.debug("Unregistering MBean {}", namingServiceObjectName);
                        getMBeanServer().unregisterMBean(namingServiceObjectName);
                    }
                } catch (Throwable t) {
                    LOG.warn("Error stopping and unregistering MBean {} due to {}", namingServiceObjectName, t.getMessage());
                }
                namingServiceObjectName = null;
            }

            if (locallyCreateMBeanServer && beanServer != null) {
                // check to see if the factory knows about this server
                List<MBeanServer> list = MBeanServerFactory.findMBeanServer(null);
                if (list != null && !list.isEmpty() && list.contains(beanServer)) {
                    LOG.debug("Releasing MBeanServer {}", beanServer);
                    MBeanServerFactory.releaseMBeanServer(beanServer);
                }
            }
            beanServer = null;
        }

        // Un-export JMX RMI registry, if it was created
        if (registry != null) {
            try {
                UnicastRemoteObject.unexportObject(registry, true);
                LOG.debug("Unexported JMX RMI Registry");
            } catch (NoSuchObjectException e) {
                LOG.debug("Error occurred while unexporting JMX RMI registry. This exception will be ignored.");
            }

            registry = null;
        }
    }

    /**
     * Gets the broker name this context is used by, may be <tt>null</tt>
     * if the broker name was not set.
     */
    public String getBrokerName() {
        return brokerName;
    }

    /**
     * Sets the broker name this context is being used by.
     */
    public void setBrokerName(String brokerName) {
        this.brokerName = brokerName;
    }

    /**
     * @return Returns the jmxDomainName.
     */
    public String getJmxDomainName() {
        return jmxDomainName;
    }

    /**
     * @param jmxDomainName The jmxDomainName to set.
     */
    public void setJmxDomainName(String jmxDomainName) {
        this.jmxDomainName = jmxDomainName;
    }

    /**
     * Get the MBeanServer
     *
     * @return the MBeanServer
     */
    public MBeanServer getMBeanServer() {
        if (this.beanServer == null) {
            this.beanServer = findMBeanServer();
        }
        return beanServer;
    }

    /**
     * Set the MBeanServer
     */
    public void setMBeanServer(MBeanServer beanServer) {
        this.beanServer = beanServer;
    }

    /**
     * @return Returns the useMBeanServer.
     */
    public boolean isUseMBeanServer() {
        return useMBeanServer;
    }

    /**
     * @param useMBeanServer The useMBeanServer to set.
     */
    public void setUseMBeanServer(boolean useMBeanServer) {
        this.useMBeanServer = useMBeanServer;
    }

    /**
     * @return Returns the createMBeanServer flag.
     */
    public boolean isCreateMBeanServer() {
        return createMBeanServer;
    }

    /**
     * @param enableJMX Set createMBeanServer.
     */
    public void setCreateMBeanServer(boolean enableJMX) {
        this.createMBeanServer = enableJMX;
    }

    public boolean isFindTigerMbeanServer() {
        return findTigerMbeanServer;
    }

    public boolean isConnectorStarted() {
        return connectorStarted.getCount() == 0 || (connectorServer != null && connectorServer.isActive());
    }

    /**
     * Enables/disables the searching for the Java 5 platform MBeanServer
     */
    public void setFindTigerMbeanServer(boolean findTigerMbeanServer) {
        this.findTigerMbeanServer = findTigerMbeanServer;
    }

    /**
     * Formulate and return the MBean ObjectName of a custom control MBean
     *
     * @return the JMX ObjectName of the MBean, or <code>null</code> if
     *         <code>customName</code> is invalid.
     */
    public ObjectName createCustomComponentMBeanName(String type, String name) {
        ObjectName result = null;
        String tmp = jmxDomainName + ":" + "type=" + sanitizeString(type) + ",name=" + sanitizeString(name);
        try {
            result = new ObjectName(tmp);
        } catch (MalformedObjectNameException e) {
            LOG.error("Couldn't create ObjectName from: {}, {}", type, name);
        }
        return result;
    }

    /**
     * The ':' and '/' characters are reserved in ObjectNames
     *
     * @return sanitized String
     */
    private static String sanitizeString(String in) {
        String result = null;
        if (in != null) {
            result = in.replace(':', '_');
            result = result.replace('/', '_');
            result = result.replace('\\', '_');
        }
        return result;
    }

    /**
     * Retrieve an System ObjectName
     */
    public static ObjectName getSystemObjectName(String domainName, String containerName, Class<?> theClass) throws MalformedObjectNameException, NullPointerException {
        String tmp = domainName + ":" + "type=" + theClass.getName() + ",name=" + getRelativeName(containerName, theClass);
        return new ObjectName(tmp);
    }

    private static String getRelativeName(String containerName, Class<?> theClass) {
        String name = theClass.getName();
        int index = name.lastIndexOf(".");
        if (index >= 0 && (index + 1) < name.length()) {
            name = name.substring(index + 1);
        }
        return containerName + "." + name;
    }

    public Object newProxyInstance(ObjectName objectName, Class<?> interfaceClass, boolean notificationBroadcaster){
        return MBeanServerInvocationHandler.newProxyInstance(getMBeanServer(), objectName, interfaceClass, notificationBroadcaster);
    }

    public Object getAttribute(ObjectName name, String attribute) throws Exception{
        return getMBeanServer().getAttribute(name, attribute);
    }

    public ObjectInstance registerMBean(Object bean, ObjectName name) throws Exception{
        ObjectInstance result = null;
        if (isAllowedToRegister(name)) {
            result = getMBeanServer().registerMBean(bean, name);
            this.registeredMBeanNames.put(name, result.getObjectName());
        }
        return result;
    }

    protected boolean isAllowedToRegister(ObjectName name) {
        boolean result = true;
        if (suppressMBean != null && suppressMBeanList != null) {
            for (ObjectName attr : suppressMBeanList) {
                if (attr.apply(name)) {
                    result = false;
                    break;
                }
            }
        }
        return result;
    }

    public Set<ObjectName> queryNames(ObjectName name, QueryExp query) throws Exception{
        if (name != null) {
            ObjectName actualName = this.registeredMBeanNames.get(name);
            if (actualName != null) {
                return getMBeanServer().queryNames(actualName, query);
            }
        }
        return getMBeanServer().queryNames(name, query);
    }

    public ObjectInstance getObjectInstance(ObjectName name) throws InstanceNotFoundException {
        return getMBeanServer().getObjectInstance(name);
    }

    /**
     * Unregister an MBean
     */
    public void unregisterMBean(ObjectName name) throws JMException {
        final ObjectName actualName = this.registeredMBeanNames.get(name);
        if (beanServer != null && actualName != null && beanServer.isRegistered(actualName) && this.registeredMBeanNames.remove(name) != null) {
            try {
                LOG.debug("Unregistering MBean {}", actualName);
                beanServer.unregisterMBean(actualName);
            } catch (javax.management.InstanceNotFoundException e) {
                // Ignore - the MBean was already unregistered (race condition during concurrent cleanup)
                // This is benign since the MBean is already gone, which is what we wanted
                LOG.trace("MBean already unregistered: {}", actualName);
            }
        }
    }

    protected synchronized MBeanServer findMBeanServer() {
        MBeanServer result = null;

        try {
            if (useMBeanServer) {
                if (findTigerMbeanServer) {
                    result = findTigerMBeanServer();
                }
                if (result == null) {
                    // lets piggy back on another MBeanServer - we could be in an appserver!
                    List<MBeanServer> list = MBeanServerFactory.findMBeanServer(null);
                    if (list != null && list.size() > 0) {
                        result = list.get(0);
                    }
                }
            }
            if (result == null && createMBeanServer) {
                result = createMBeanServer();
            }
        } catch (NoClassDefFoundError e) {
            LOG.error("Could not load MBeanServer", e);
        } catch (Throwable e) {
            // probably don't have access to system properties
            LOG.error("Failed to initialize MBeanServer", e);
        }
        return result;
    }

    public MBeanServer findTigerMBeanServer() {
        String name = "java.lang.management.ManagementFactory";
        Class<?> type = loadClass(name, ManagementContext.class.getClassLoader());
        if (type != null) {
            try {
                Method method = type.getMethod("getPlatformMBeanServer", new Class[0]);
                if (method != null) {
                    Object answer = method.invoke(null, new Object[0]);
                    if (answer instanceof MBeanServer) {
                        if (createConnector) {
                            createConnector((MBeanServer)answer);
                        }
                        return (MBeanServer)answer;
                    } else {
                        LOG.warn("Could not cast: {} into an MBeanServer. There must be some classloader strangeness in town", answer);
                    }
                } else {
                    LOG.warn("Method getPlatformMBeanServer() does not appear visible on type: {}", type.getName());
                }
            } catch (Exception e) {
                LOG.warn("Failed to call getPlatformMBeanServer() due to: ", e);
            }
        } else {
            LOG.trace("Class not found: {} so probably running on Java 1.4", name);
        }
        return null;
    }

    private static Class<?> loadClass(String name, ClassLoader loader) {
        try {
            return loader.loadClass(name);
        } catch (ClassNotFoundException e) {
            try {
                return Thread.currentThread().getContextClassLoader().loadClass(name);
            } catch (ClassNotFoundException e1) {
                return null;
            }
        }
    }

    /**
     * @return an MBeanServer instance
     */
    protected MBeanServer createMBeanServer() throws MalformedObjectNameException, IOException {
        MBeanServer mbeanServer = MBeanServerFactory.createMBeanServer(jmxDomainName);
        locallyCreateMBeanServer = true;
        if (createConnector) {
            createConnector(mbeanServer);
        }
        return mbeanServer;
    }

    private void createConnector(MBeanServer mbeanServer) throws IOException {
        // Create the NamingService, needed by JSR 160
        try {
            if (registry == null) {
                LOG.debug("Creating RMIRegistry on port {}", connectorPort);
                registry = jmxRegistry(connectorPort);
            }

            namingServiceObjectName = ObjectName.getInstance("naming:type=rmiregistry");

            // Do not use the createMBean as the mx4j jar may not be in the
            // same class loader than the server
            Class<?> cl = Class.forName("mx4j.tools.naming.NamingService");
            mbeanServer.registerMBean(cl.getDeclaredConstructor().newInstance(), namingServiceObjectName);

            // set the naming port
            Attribute attr = new Attribute("Port", connectorPort);
            mbeanServer.setAttribute(namingServiceObjectName, attr);
        } catch(ClassNotFoundException e) {
            LOG.warn("Probably not using JRE 1.4: {}", e.getLocalizedMessage());
        } catch (Throwable e) {
            LOG.warn("Failed to create local RMI registry on port {}. This may happen when another process or broker instance "
                    + "is already using the port. To resolve, either change the connectorPort in your managementContext configuration "
                    + "or set createConnector=\"false\" if remote JMX access is not required. This exception will be ignored.", connectorPort, e);
        }

        // Create the JMXConnectorServer
        String rmiServer = "";
        if (rmiServerPort != 0) {
            // This is handy to use if you have a firewall and need to force JMX to use fixed ports.
            rmiServer = ""+getConnectorHost()+":" + rmiServerPort;
        }

        server = new RMIJRMPServerImpl(connectorPort, null, null, environment);

        final String serviceURL = "service:jmx:rmi://" + rmiServer + "/jndi/rmi://" +getConnectorHost()+":" + connectorPort + connectorPath;
        final JMXServiceURL url = new JMXServiceURL(serviceURL);

        connectorServer = new RMIConnectorServer(url, environment, server, ManagementFactory.getPlatformMBeanServer());
        LOG.debug("Created JMXConnectorServer {}", connectorServer);
    }

    public String getConnectorPath() {
        return connectorPath;
    }

    public void setConnectorPath(String connectorPath) {
        this.connectorPath = connectorPath;

        if (connectorPath == null || connectorPath.length() == 0) {
            this.lookupName = DEFAULT_LOOKUP_NAME;
        } else {
            this.lookupName = connectorPath.replaceAll("^/+", "").replaceAll("/+$", "");
        }
    }

    public int getConnectorPort() {
        return connectorPort;
    }

    /**
     * @org.apache.xbean.Property propertyEditor="org.apache.activemq.util.MemoryIntPropertyEditor"
     */
    public void setConnectorPort(int connectorPort) {
        this.connectorPort = connectorPort;
    }

    public int getRmiServerPort() {
        return rmiServerPort;
    }

    /**
     * @org.apache.xbean.Property propertyEditor="org.apache.activemq.util.MemoryIntPropertyEditor"
     */
    public void setRmiServerPort(int rmiServerPort) {
        this.rmiServerPort = rmiServerPort;
    }

    public boolean isCreateConnector() {
        return createConnector;
    }

    /**
     * @org.apache.xbean.Property propertyEditor="org.apache.activemq.util.BooleanEditor"
     */
    public void setCreateConnector(boolean createConnector) {
        this.createConnector = createConnector;
    }

    /**
     * Get the connectorHost
     * @return the connectorHost
     */
    public String getConnectorHost() {
        return this.connectorHost;
    }

    /**
     * Set the connectorHost
     * @param connectorHost the connectorHost to set
     */
    public void setConnectorHost(String connectorHost) {
        this.connectorHost = connectorHost;
    }

    public Map<String, ?> getEnvironment() {
        return environment;
    }

    public void setEnvironment(Map<String, ?> environment) {
        this.environment = environment;
    }

    public boolean isAllowRemoteAddressInMBeanNames() {
        return allowRemoteAddressInMBeanNames;
    }

    public void setAllowRemoteAddressInMBeanNames(boolean allowRemoteAddressInMBeanNames) {
        this.allowRemoteAddressInMBeanNames = allowRemoteAddressInMBeanNames;
    }

    /**
     * Allow selective MBeans registration to be suppressed. Any Mbean ObjectName that matches any
     * of the supplied attribute values will not be registered with the MBeanServer.
     * eg: "endpoint=dynamicProducer,endpoint=Consumer" will suppress the registration of *all* dynamic producer and consumer mbeans.
     *
     * @param commaListOfAttributeKeyValuePairs  the comma separated list of attribute key=value pairs to match.
     */
    public void setSuppressMBean(String commaListOfAttributeKeyValuePairs) {
        this.suppressMBean = commaListOfAttributeKeyValuePairs;
    }

    public String getSuppressMBean() {
        return suppressMBean;
    }

    // do not use sun.rmi.registry.RegistryImpl! it is not always easily available
    private Registry jmxRegistry(final int port) throws RemoteException {
        final var loader = Thread.currentThread().getContextClassLoader();
        final var delegate = LocateRegistry.createRegistry(port);
        return Registry.class.cast(Proxy.newProxyInstance(
                loader == null ? getSystemClassLoader() : loader,
                new Class<?>[]{Registry.class}, (proxy, method, args) -> {
                    final var name = method.getName();
                    if ("lookup".equals(name) &&
                            method.getParameterCount() == 1 &&
                            method.getParameterTypes()[0] == String.class) {
                        return lookupName.equals(args[0]) ? serverStub : null;
                    }
                    switch (name) {
                        case "bind":
                        case "unbind":
                        case "rebind":
                            return null;
                        case "list":
                            return new String[] {lookupName};
                    }
                    try {
                        return method.invoke(delegate, args);
                    } catch (final InvocationTargetException ite) {
                        throw ite.getTargetException();
                    }
                }));
    }
}
