/*
 * 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.brooklyn.entity.java;

import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;

import java.net.MalformedURLException;
import java.security.KeyStore;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArrayList;

import javax.management.remote.JMXConnector;
import javax.management.remote.JMXConnectorFactory;
import javax.management.remote.JMXServiceURL;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;

import org.apache.brooklyn.api.entity.EntitySpec;
import org.apache.brooklyn.api.sensor.SensorEvent;
import org.apache.brooklyn.api.sensor.SensorEventListener;
import org.apache.brooklyn.core.entity.Entities;
import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
import org.apache.brooklyn.core.location.PortRanges;
import org.apache.brooklyn.core.test.entity.TestApplication;
import org.apache.brooklyn.entity.java.JavaAppUtils;
import org.apache.brooklyn.entity.java.UsesJava;
import org.apache.brooklyn.entity.java.UsesJmx;
import org.apache.brooklyn.entity.java.VanillaJavaApp;
import org.apache.brooklyn.feed.jmx.JmxHelper;
import org.apache.brooklyn.test.Asserts;
import org.apache.brooklyn.util.collections.MutableMap;
import org.apache.brooklyn.util.core.ResourceUtils;
import org.apache.brooklyn.util.core.crypto.FluentKeySigner;
import org.apache.brooklyn.util.core.crypto.SecureKeys;
import org.apache.brooklyn.util.crypto.SslTrustUtils;
import org.apache.brooklyn.util.jmx.jmxmp.JmxmpAgent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import org.apache.brooklyn.location.localhost.LocalhostMachineProvisioningLocation;

import com.google.common.base.Predicate;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;

public class VanillaJavaAppTest {

    private static final Logger LOG = LoggerFactory.getLogger(VanillaJavaAppTest.class);
    
    private static final long TIMEOUT_MS = 10*1000;

    // Static attributes such as number of processors and start time are only polled every 60 seconds
    // so if they are not immediately available, it will be 60 seconds before they are polled again
    private static final Object LONG_TIMEOUT_MS = 61*1000;

    private static String BROOKLYN_THIS_CLASSPATH = null;
    private static Class<?> MAIN_CLASS = ExampleVanillaMain.class;
    private static Class<?> MAIN_CPU_HUNGRY_CLASS = ExampleVanillaMainCpuHungry.class;
    
    private TestApplication app;
    private LocalhostMachineProvisioningLocation loc;

    @BeforeMethod(alwaysRun = true)
    public void setUp() throws Exception {
        if (BROOKLYN_THIS_CLASSPATH==null) {
            BROOKLYN_THIS_CLASSPATH = ResourceUtils.create(MAIN_CLASS).getClassLoaderDir();
        }
        app = TestApplication.Factory.newManagedInstanceForTests();
        loc = app.newLocalhostProvisioningLocation(MutableMap.of("address", "localhost"));
    }

    @AfterMethod(alwaysRun = true)
    public void tearDown() throws Exception {
        if (app != null) Entities.destroyAll(app.getManagementContext());
    }

    @Test
    public void testReadsConfigFromFlags() throws Exception {
        final VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", "my.Main").configure("classpath", ImmutableList.of("c1", "c2"))
            .configure("args", ImmutableList.of("a1", "a2")));

        assertEquals(javaProcess.getMainClass(), "my.Main");
        assertEquals(javaProcess.getClasspath(), ImmutableList.of("c1","c2"));
        assertEquals(javaProcess.getConfig(VanillaJavaApp.ARGS), ImmutableList.of("a1", "a2"));
    }

    @Test(groups={"WIP", "Integration"})
    public void testJavaSystemProperties() throws Exception {
        final VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", "my.Main").configure("classpath", ImmutableList.of("c1", "c2"))
            .configure("args", ImmutableList.of("a1", "a2")));
        javaProcess.config().set(UsesJava.JAVA_SYSPROPS, ImmutableMap.of("fooKey", "fooValue", "barKey", "barValue"));
        // TODO: how to test: launch standalone app that outputs system properties to stdout? Probe via JMX?
    }

    @Test(groups={"Integration"})
    public void testStartsAndStops() throws Exception {
        String main = MAIN_CLASS.getCanonicalName();
        final VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", main).configure("classpath", ImmutableList.of(BROOKLYN_THIS_CLASSPATH))
            .configure("args", ImmutableList.of()));
        app.start(ImmutableList.of(loc));
        assertEquals(javaProcess.getAttribute(VanillaJavaApp.SERVICE_STATE_ACTUAL), Lifecycle.RUNNING);

        javaProcess.stop();
        assertEquals(javaProcess.getAttribute(VanillaJavaApp.SERVICE_STATE_ACTUAL), Lifecycle.STOPPED);
    }

    @Test(groups={"Integration"})
    public void testHasJvmMXBeanSensorVals() throws Exception {
        String main = MAIN_CLASS.getCanonicalName();
        final VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", main).configure("classpath", ImmutableList.of(BROOKLYN_THIS_CLASSPATH))
            .configure("args", ImmutableList.of()));
        app.start(ImmutableList.of(loc));
        
        // Memory MXBean
        Asserts.succeedsEventually(MutableMap.of("timeout", TIMEOUT_MS), new Runnable() {
            @Override
            public void run() {
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.NON_HEAP_MEMORY_USAGE));
                long init = javaProcess.getAttribute(VanillaJavaApp.INIT_HEAP_MEMORY);
                long used = javaProcess.getAttribute(VanillaJavaApp.USED_HEAP_MEMORY);
                long committed = javaProcess.getAttribute(VanillaJavaApp.COMMITTED_HEAP_MEMORY);
                long max = javaProcess.getAttribute(VanillaJavaApp.MAX_HEAP_MEMORY);
    
                assertNotNull(used);
                assertNotNull(init);
                assertNotNull(committed);
                assertNotNull(max);
                assertTrue(init <= max, String.format("init %d > max %d heap memory", init, max));
                assertTrue(used <= committed, String.format("used %d > committed %d heap memory", used, committed));
                assertTrue(committed <= max, String.format("committed %d > max %d heap memory", committed, max));
            }});
        
        // Threads MX Bean
        Asserts.succeedsEventually(MutableMap.of("timeout", TIMEOUT_MS), new Runnable() {
            @Override
            public void run() {
                long current = javaProcess.getAttribute(VanillaJavaApp.CURRENT_THREAD_COUNT);
                long peak = javaProcess.getAttribute(VanillaJavaApp.PEAK_THREAD_COUNT);
    
                assertNotNull(current);
                assertNotNull(peak);
                assertTrue(current <= peak, String.format("current %d > peak %d thread count", current, peak));
            }});

        // Runtime MX Bean
        Asserts.succeedsEventually(MutableMap.of("timeout", LONG_TIMEOUT_MS), new Runnable() {
            @Override
            public void run() {
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.START_TIME));
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.UP_TIME));
            }});
        
        // Operating System MX Bean
        Asserts.succeedsEventually(MutableMap.of("timeout", LONG_TIMEOUT_MS), new Runnable() {
            @Override
            public void run() {
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.PROCESS_CPU_TIME));
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.SYSTEM_LOAD_AVERAGE));
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.AVAILABLE_PROCESSORS));
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.TOTAL_PHYSICAL_MEMORY_SIZE));
                assertNotNull(javaProcess.getAttribute(VanillaJavaApp.FREE_PHYSICAL_MEMORY_SIZE));
            }});
        // TODO work on providing useful metrics from garbage collector MX Bean
        // assertNotNull(javaProcess.getAttribute(VanillaJavaApp.GARBAGE_COLLECTION_TIME)) TODO: work on providing this
    }
    
    @Test(groups={"Integration"})
    public void testJvmMXBeanProcessCpuTimeGivesNonZeroPercentage() throws Exception {
        String main = MAIN_CPU_HUNGRY_CLASS.getCanonicalName();
        final VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", main).configure("classpath", ImmutableList.of(BROOKLYN_THIS_CLASSPATH))
            .configure("args", ImmutableList.of()));
        app.start(ImmutableList.of(loc));

        JavaAppUtils.connectJavaAppServerPolicies(javaProcess);
        
        final List<Double> fractions = new CopyOnWriteArrayList<Double>();
        app.getManagementContext().getSubscriptionManager().subscribe(javaProcess, VanillaJavaApp.PROCESS_CPU_TIME_FRACTION_LAST, new SensorEventListener<Double>() {
                @Override
                public void onEvent(SensorEvent<Double> event) {
                    fractions.add(event.getValue());
                }});
        
        // Expect non-trivial load to be generated by the process.
        // Expect load to be in the right order of magnitude (to ensure we haven't got a decimal point in the wrong place etc);
        // But with multi-core could get big number; and on jenkins@releng3 we once saw [11.9, 0.6, 0.5]!
        Asserts.succeedsEventually(new Runnable() {
            @Override
            public void run() {
                Iterable<Double> nonTrivialFractions = Iterables.filter(fractions, new Predicate<Double>() {
                        @Override
                        public boolean apply(Double input) {
                            return input > 0.01;
                        }});
                assertTrue(Iterables.size(nonTrivialFractions) > 3, "fractions="+fractions); 
            }});

        Iterable<Double> tooBigFractions = Iterables.filter(fractions, new Predicate<Double>() {
                @Override
                public boolean apply(Double input) {
                    return input > 50;
                }});
        assertTrue(Iterables.isEmpty(tooBigFractions), "fractions="+fractions); 
        
        Iterable<Double> ballparkRightFractions = Iterables.filter(fractions, new Predicate<Double>() {
                @Override
                public boolean apply(Double input) {
                    return input > 0.01 && input < 4;
                }});
        assertTrue(Iterables.size(ballparkRightFractions) >= (fractions.size() / 2), "fractions="+fractions);
        
        LOG.info("VanillaJavaApp->ExampleVanillaMainCpuHuntry: ProcessCpuTime fractions="+fractions);
    }

    @Test(groups={"Integration"})
    public void testStartsWithJmxPortSpecifiedInConfig() throws Exception {
        int port = 53405;
        String main = MAIN_CLASS.getCanonicalName();
        VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", main).configure("classpath", ImmutableList.of(BROOKLYN_THIS_CLASSPATH))
            .configure("args", ImmutableList.of()));
        javaProcess.config().set(UsesJmx.JMX_PORT, PortRanges.fromInteger(port));
        app.start(ImmutableList.of(loc));

        assertEquals(javaProcess.getAttribute(UsesJmx.JMX_PORT), (Integer)port);
    }

    // FIXME Way test was written requires JmxSensorAdapter; need to rewrite...  
    @Test(groups={"Integration", "WIP"})
    public void testStartsWithSecureJmxPortSpecifiedInConfig() throws Exception {
        int port = 53406;
        String main = MAIN_CLASS.getCanonicalName();
        final VanillaJavaApp javaProcess = app.createAndManageChild(EntitySpec.create(VanillaJavaApp.class)
            .configure("main", main).configure("classpath", ImmutableList.of(BROOKLYN_THIS_CLASSPATH))
            .configure("args", ImmutableList.of()));
        javaProcess.config().set(UsesJmx.JMX_PORT, PortRanges.fromInteger(port));
        javaProcess.config().set(UsesJmx.JMX_SSL_ENABLED, true);
        
        app.start(ImmutableList.of(loc));
        // will fail above if JMX can't connect, but also do some add'l checks
        
        assertEquals(javaProcess.getAttribute(UsesJmx.JMX_PORT), (Integer)port);

        // good key+cert succeeds
        new AsserterForJmxConnection(javaProcess)
                .customizeSocketFactory(null, null)
                .connect();
        
        // bad cert fails
        Asserts.assertFails(new Callable<Void>() {
            @Override
            public Void call() throws Exception {
                new AsserterForJmxConnection(javaProcess)
                        .customizeSocketFactory(null, new FluentKeySigner("cheater").newCertificateFor("jmx-access-key", SecureKeys.newKeyPair()))
                        .connect();
                return null;
            }});

        // bad key fails
        Asserts.assertFails(new Callable<Void>() {
            @Override
            public Void call() throws Exception {
                new AsserterForJmxConnection(javaProcess)
                        .customizeSocketFactory(SecureKeys.newKeyPair().getPrivate(), null)
                        .connect();
                return null;
            }});
        
        // bad profile fails
        Asserts.assertFails(new Callable<Void>() {
            @Override
            public Void call() throws Exception {
                AsserterForJmxConnection asserter = new AsserterForJmxConnection(javaProcess);
                asserter.putEnv("jmx.remote.profiles", JmxmpAgent.TLS_JMX_REMOTE_PROFILES);
                asserter.customizeSocketFactory(SecureKeys.newKeyPair().getPrivate(), null)
                        .connect();
                return null;
            }});
    }

    private static class AsserterForJmxConnection {
        final VanillaJavaApp entity;
        final JMXServiceURL url;
        final Map<String,Object> env;
        
        @SuppressWarnings("unchecked")
        public AsserterForJmxConnection(VanillaJavaApp e) throws MalformedURLException {
            this.entity = e;
            
            JmxHelper jmxHelper = new JmxHelper(entity);
            this.url = new JMXServiceURL(jmxHelper.getUrl());
            this.env = Maps.newLinkedHashMap(jmxHelper.getConnectionEnvVars());
        }
        
        public JMXServiceURL getJmxUrl() throws MalformedURLException {
            return url;
        }
        
        public void putEnv(String key, Object val) {
            env.put(key, val);
        }
        
        public AsserterForJmxConnection customizeSocketFactory(PrivateKey customKey, Certificate customCert) throws Exception {
            PrivateKey key = (customKey == null) ? entity.getConfig(UsesJmx.JMX_SSL_ACCESS_KEY) : customKey;
            Certificate cert = (customCert == null) ? entity.getConfig(UsesJmx.JMX_SSL_ACCESS_CERT) : customCert;
            
            KeyStore ks = SecureKeys.newKeyStore();
            KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
            if (key!=null) {
                ks.setKeyEntry("brooklyn-jmx-access", key, "".toCharArray(), new Certificate[] {cert});
            }
            kmf.init(ks, "".toCharArray());

            TrustManager tms =
            // TODO use root cert for trusting server
            //trustStore!=null ? SecureKeys.getTrustManager(trustStore) :
                SslTrustUtils.TRUST_ALL;

            SSLContext ctx = SSLContext.getInstance("TLSv1");
            ctx.init(kmf.getKeyManagers(), new TrustManager[] {tms}, null);
            SSLSocketFactory ssf = ctx.getSocketFactory();
            env.put(JmxmpAgent.TLS_SOCKET_FACTORY_PROPERTY, ssf);
            
            return this;
        }
        
        public JMXConnector connect() throws Exception {
            return JMXConnectorFactory.connect(getJmxUrl(), env);
        }
    }
}
