/*
 * 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.location.ssh;

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

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.OutputStream;
import java.net.InetAddress;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Callable;

import org.apache.brooklyn.api.location.Location;
import org.apache.brooklyn.api.location.LocationSpec;
import org.apache.brooklyn.api.location.MachineDetails;
import org.apache.brooklyn.core.BrooklynLogging;
import org.apache.brooklyn.core.entity.AbstractEntity;
import org.apache.brooklyn.core.internal.BrooklynProperties;
import org.apache.brooklyn.location.localhost.LocalhostMachineProvisioningLocation;
import org.apache.brooklyn.test.Asserts;
import org.apache.brooklyn.test.LogWatcher;
import org.apache.brooklyn.test.LogWatcher.EventPredicates;
import org.apache.brooklyn.util.collections.MutableMap;
import org.apache.brooklyn.util.core.crypto.SecureKeys;
import org.apache.brooklyn.util.core.file.ArchiveUtils;
import org.apache.brooklyn.util.core.internal.ssh.SshException;
import org.apache.brooklyn.util.core.internal.ssh.SshTool;
import org.apache.brooklyn.util.core.internal.ssh.sshj.SshjTool;
import org.apache.brooklyn.util.core.internal.ssh.sshj.SshjTool.SshjToolBuilder;
import org.apache.brooklyn.util.core.task.BasicExecutionContext;
import org.apache.brooklyn.util.core.task.BasicExecutionManager;
import org.apache.brooklyn.util.guava.Maybe;
import org.apache.brooklyn.util.net.Networking;
import org.apache.brooklyn.util.net.Urls;
import org.apache.brooklyn.util.os.Os;
import org.apache.brooklyn.util.stream.Streams;
import org.apache.brooklyn.util.time.Duration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testng.Assert;
import org.testng.annotations.Test;

import com.google.common.base.Charsets;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.io.Files;

import ch.qos.logback.classic.spi.ILoggingEvent;

public class SshMachineLocationIntegrationTest extends SshMachineLocationTest {

    private static final Logger LOG = LoggerFactory.getLogger(AbstractEntity.class);

    @Override
    protected BrooklynProperties getBrooklynProperties() {
        // Requires location named "localhost-passphrase", which it expects to find in local 
        // brooklyn.properties (or brooklyn.cfg in karaf).
        return BrooklynProperties.Factory.newDefault();
    }
    
    @Override
    protected SshMachineLocation newHost() {
        return mgmt.getLocationManager().createLocation(LocationSpec.create(SshMachineLocation.class)
                .configure("address", Networking.getReachableLocalHost()));
    }

    // Overridden just to make it integration (because `newHost()` returns a real ssh'ing host)
    @Test(groups="Integration")
    @Override
    public void testSshExecScript() throws Exception {
        super.testSshExecScript();
    }
    
    // Overridden just to make it integration (because `newHost()` returns a real ssh'ing host)
    @Test(groups="Integration")
    @Override
    public void testSshExecCommands() throws Exception {
        super.testSshExecCommands();
    }
    
    @Test(groups="Integration")
    public void testIsSshableWhenTrue() throws Exception {
        assertTrue(host.isSshable());
    }

    // Overridden just to make it integration (because `newHost()` returns a real ssh'ing host)
    @Test(groups="Integration")
    @Override
    public void testDoesNotLogPasswordsInEnvironmentVariables() {
        super.testDoesNotLogPasswordsInEnvironmentVariables();
    }

    // Overrides super, because expect real machine details (rather than asserting our stub data)
    @Test(groups = "Integration")
    @Override
    public void testGetMachineDetails() throws Exception {
        BasicExecutionManager execManager = new BasicExecutionManager("mycontextid");
        BasicExecutionContext execContext = new BasicExecutionContext(execManager);
        try {
            MachineDetails details = execContext.submit("get details", () -> host.getMachineDetails()).get();
            LOG.info("machineDetails="+details);
            assertNotNull(details);
        } finally {
            execManager.shutdownNow();
        }
    }

    // Overrides and disables super, because real machine won't give extra stdout
    @Test(enabled=false)
    @Override
    public void testGetMachineDetailsWithExtraStdout() throws Exception {
        throw new UnsupportedOperationException("Test disabled because real machine does not have extra stdout");
    }

    @Test(groups = "Integration")
    public void testCopyFileTo() throws Exception {
        File dest = Os.newTempFile(getClass(), ".dest.tmp");
        File src = Os.newTempFile(getClass(), ".src.tmp");
        try {
            Files.write("abc", src, Charsets.UTF_8);
            host.copyTo(src, dest);
            assertEquals("abc", Files.readFirstLine(dest, Charsets.UTF_8));
        } finally {
            src.delete();
            dest.delete();
        }
    }

    // Note: requires `ssh localhost` to be setup such that no password is required    
    @Test(groups = "Integration")
    public void testCopyStreamTo() throws Exception {
        String contents = "abc";
        File dest = new File(Os.tmp(), "sshMachineLocationTest_dest.tmp");
        try {
            host.copyTo(Streams.newInputStreamWithContents(contents), dest.getAbsolutePath());
            assertEquals("abc", Files.readFirstLine(dest, Charsets.UTF_8));
        } finally {
            dest.delete();
        }
    }

    // Requires internet connectivity; on guest wifi etc can fail with things like
    // "Welcome to Virgin Trains" etc.
    @Test(groups = "Integration")
    public void testInstallUrlTo() throws Exception {
        File dest = new File(Os.tmp(), "sshMachineLocationTest_dir/");
        dest.mkdir();
        try {
            int result = host.installTo("https://raw.github.com/brooklyncentral/brooklyn/master/README.md", Urls.mergePaths(dest.getAbsolutePath(), "README.md"));
            assertEquals(result, 0);
            String contents = ArchiveUtils.readFullyString(new File(dest, "README.md"));
            assertTrue(contents.contains("http://brooklyncentral.github.com"), "contents missing expected phrase; contains:\n"+contents);
        } finally {
            dest.delete();
        }
    }
    
    @Test(groups = "Integration")
    public void testInstallClasspathCopyTo() throws Exception {
        File dest = new File(Os.tmp(), "sshMachineLocationTest_dir/");
        dest.mkdir();
        try {
            int result = host.installTo("classpath://brooklyn/config/sample.properties", Urls.mergePaths(dest.getAbsolutePath(), "sample.properties"));
            assertEquals(result, 0);
            String contents = ArchiveUtils.readFullyString(new File(dest, "sample.properties"));
            assertTrue(contents.contains("Property 1"), "contents missing expected phrase; contains:\n"+contents);
        } finally {
            dest.delete();
        }
    }

    // See http://superuser.com/a/698251/497648 for choice of unreachable IP.
    // Even with 240.0.0.0, it takes a long time (75 seconds in office network).
    //
    // Previously, "123.123.123.123" would seemingly hang on some (home/airport) networks.
    // Times out (with 123.123.123.123) in 2m7s on Ubuntu Vivid (syn retries set to 6)
    //
    // Make sure we fail, waiting for longer than the 70 second TCP timeout.
    @Test(groups = "Integration")
    public void testIsSshableWhenFalse() throws Exception {
        String unreachableIp = "240.0.0.0";
        final SshMachineLocation unreachableHost = new SshMachineLocation(MutableMap.of("address", InetAddress.getByName(unreachableIp)));
        System.out.println(unreachableHost.getAddress());
        Asserts.assertReturnsEventually(new Runnable() {
            @Override
            public void run() {
                assertFalse(unreachableHost.isSshable());
            }},
            Duration.minutes(3));
    }

    // For issue #230
    @Test(groups = "Integration")
    public void testOverridingPropertyOnExec() throws Exception {
        SshMachineLocation host = new SshMachineLocation(MutableMap.of("address", Networking.getReachableLocalHost(), "sshPrivateKeyData", "wrongdata"));
        
        OutputStream outStream = new ByteArrayOutputStream();
        String expectedName = Os.user();
        host.execCommands(MutableMap.of("sshPrivateKeyData", null, "out", outStream), "my summary", ImmutableList.of("whoami"));
        String outString = outStream.toString();
        
        assertTrue(outString.contains(expectedName), "outString="+outString);
    }

    @Test(groups = "Integration", expectedExceptions={IllegalStateException.class, SshException.class})
    public void testSshRunWithInvalidUserFails() throws Exception {
        SshMachineLocation badHost = new SshMachineLocation(MutableMap.of("user", "doesnotexist", "address", Networking.getReachableLocalHost()));
        badHost.execScript("mysummary", ImmutableList.of("whoami; exit"));
    }
    
    // Note: requires `named:localhost-passphrase` set up with a key whose passphrase is "localhost"
    // * create the key with:
    //      ssh-keygen -t rsa -N "brooklyn" -f ~/.ssh/id_rsa_passphrase
    //      ssh-copy-id localhost
    // * create brooklyn.properties, containing:
    //      brooklyn.location.named.localhost-passphrase=localhost
    //      brooklyn.location.named.localhost-passphrase.privateKeyFile=~/.ssh/id_rsa_passphrase
    //      brooklyn.location.named.localhost-passphrase.privateKeyPassphrase=brooklyn
    @Test(groups = "Integration")
    public void testExtractingConnectablePassphraselessKey() throws Exception {
        Maybe<LocationSpec<? extends Location>> lhps = mgmt.getLocationRegistry().getLocationSpec("named:localhost-passphrase");
        Preconditions.checkArgument(lhps.isPresent(), "This test requires a localhost named location called 'localhost-passphrase' (which should have a passphrase set)");
        LocalhostMachineProvisioningLocation lhp = (LocalhostMachineProvisioningLocation) mgmt.getLocationManager().createLocation(lhps.get());
        SshMachineLocation sm = lhp.obtain();
        
        SshjToolBuilder builder = SshjTool.builder().host(sm.getAddress().getHostName()).user(sm.getUser());
        
        KeyPair data = sm.findKeyPair();
        if (data!=null) builder.privateKeyData(SecureKeys.toPem(data));
        String password = sm.findPassword();
        if (password!=null) builder.password(password);
        SshjTool tool = builder.build();
        tool.connect();
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int result = tool.execCommands(MutableMap.<String,Object>of("out", out), Arrays.asList("date"));
        Assert.assertTrue(out.toString().contains(" 20"), "out="+out);
        assertEquals(result, 0);
    }

    @Test(groups = "Integration")
    public void testExecScriptScriptDirFlagIsRespected() throws Exception {
        // For explanation of (some of) the magic behind this command, see http://stackoverflow.com/a/229606/68898
        final String command = "if [[ \"$0\" == \"/var/tmp/\"* ]]; then true; else false; fi";

        LocalhostMachineProvisioningLocation lhp = mgmt.getLocationManager().createLocation(LocationSpec.create(LocalhostMachineProvisioningLocation.class));
        SshMachineLocation sm = lhp.obtain();

        Map<String, Object> props = ImmutableMap.<String, Object>builder()
                .put(SshTool.PROP_SCRIPT_DIR.getName(), "/var/tmp")
                .build();
        int rc = sm.execScript(props, "Test script directory execution", ImmutableList.of(command));
        assertEquals(rc, 0);
    }

    @Test(groups = "Integration")
    public void testLocationScriptDirConfigIsRespected() throws Exception {
        // For explanation of (some of) the magic behind this command, see http://stackoverflow.com/a/229606/68898
        final String command = "if [[ \"$0\" == \"/var/tmp/\"* ]]; then true; else false; fi";

        Map<String, Object> locationConfig = ImmutableMap.<String, Object>builder()
                .put(SshMachineLocation.SCRIPT_DIR.getName(), "/var/tmp")
                .build();

        LocalhostMachineProvisioningLocation lhp = 
            mgmt.getLocationManager().createLocation(LocationSpec.create(LocalhostMachineProvisioningLocation.class)
                .configure(locationConfig));
        SshMachineLocation sm = lhp.obtain();

        int rc = sm.execScript("Test script directory execution", ImmutableList.of(command));
        assertEquals(rc, 0);
    }
    
    @Test(groups = "Integration")
    public void testMissingLocationScriptDirIsAlsoOkay() throws Exception {
        final String command = "echo hello";

        Map<String, Object> locationConfig = ImmutableMap.<String, Object>builder()
//                .put(SshMachineLocation.SCRIPT_DIR.getName(), "/var/tmp")
                .build();

        LocalhostMachineProvisioningLocation lhp = 
            mgmt.getLocationManager().createLocation(LocationSpec.create(LocalhostMachineProvisioningLocation.class)
                .configure(locationConfig));
        SshMachineLocation sm = lhp.obtain();

        int rc = sm.execScript("Test script directory execution", ImmutableList.of(command));
        assertEquals(rc, 0);
    }
    
    @Test(groups="Integration")
    public void testLogsStdoutAndStderr() {
        List<String> loggerNames = ImmutableList.of(
                SshMachineLocation.class.getName(), 
                BrooklynLogging.SSH_IO, 
                SshjTool.class.getName());
        ch.qos.logback.classic.Level logLevel = ch.qos.logback.classic.Level.DEBUG;
        Predicate<ILoggingEvent> filter = Predicates.alwaysTrue();
        try (LogWatcher watcher = new LogWatcher(loggerNames, logLevel, filter)) {

            host.execCommands("mySummary", ImmutableList.of("echo mystdout1", "echo mystdout2", "echo mystderr1 1>&2", "echo mystderr2 1>&2"));
            
            watcher.assertHasEvent(EventPredicates.containsMessage(":22:stdout] mystdout1"));
            watcher.assertHasEvent(EventPredicates.containsMessage(":22:stdout] mystdout2"));
            watcher.assertHasEvent(EventPredicates.containsMessage(":22:stderr] mystderr1"));
            watcher.assertHasEvent(EventPredicates.containsMessage(":22:stderr] mystderr2"));
        }
    }
    
    @Test(groups="Integration")
    public void testTurningOffLoggingStdoutAndStderr() {
        super.testTurningOffLoggingStdoutAndStderr();
    }
}
