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

import static org.apache.brooklyn.core.config.ConfigKeys.newConfigKeyWithPrefix;
import static org.apache.brooklyn.core.config.ConfigKeys.newStringConfigKey;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.net.InetAddress;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.Nullable;

import org.apache.brooklyn.api.location.MachineDetails;
import org.apache.brooklyn.api.location.MachineLocation;
import org.apache.brooklyn.api.location.OsDetails;
import org.apache.brooklyn.config.ConfigKey;
import org.apache.brooklyn.config.ConfigKey.HasConfigKey;
import org.apache.brooklyn.core.config.ConfigKeys;
import org.apache.brooklyn.core.config.ConfigUtils;
import org.apache.brooklyn.core.config.Sanitizer;
import org.apache.brooklyn.core.entity.BrooklynConfigKeys;
import org.apache.brooklyn.core.location.AbstractLocation;
import org.apache.brooklyn.core.location.access.PortForwardManager;
import org.apache.brooklyn.core.location.access.PortForwardManagerLocationResolver;
import org.apache.brooklyn.core.mgmt.ManagementContextInjectable;
import org.apache.brooklyn.util.core.ClassLoaderUtils;
import org.apache.brooklyn.util.core.config.ConfigBag;
import org.apache.brooklyn.util.core.internal.ssh.SshTool;
import org.apache.brooklyn.util.core.internal.winrm.WinRmTool;
import org.apache.brooklyn.util.core.internal.winrm.WinRmToolResponse;
import org.apache.brooklyn.util.core.internal.winrm.winrm4j.Winrm4jTool;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.guava.Maybe;
import org.apache.brooklyn.util.stream.Streams;
import org.apache.brooklyn.util.text.Strings;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.annotations.Beta;
import com.google.common.base.Charsets;
import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.net.HostAndPort;
import com.google.common.reflect.TypeToken;

public class WinRmMachineLocation extends AbstractLocation implements MachineLocation {

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

    public static final ConfigKey<InetAddress> ADDRESS = ConfigKeys.newConfigKey(
            InetAddress.class,
            "address",
            "Address of the remote machine");

    public static final ConfigKey<Integer> WINRM_CONFIG_PORT = newConfigKeyWithPrefix(BrooklynConfigKeys.BROOKLYN_WINRM_CONFIG_KEY_PREFIX, WinRmTool.PROP_PORT);
    public static final ConfigKey<String> OPERATION_TIMEOUT = newConfigKeyWithPrefix(BrooklynConfigKeys.BROOKLYN_WINRM_CONFIG_KEY_PREFIX, WinRmTool.OPERATION_TIMEOUT);
    public static final ConfigKey<Integer> RETRIES_OF_NETWORK_FAILURES = newConfigKeyWithPrefix(BrooklynConfigKeys.BROOKLYN_WINRM_CONFIG_KEY_PREFIX, WinRmTool.RETRIES_OF_NETWORK_FAILURES);
    public static final ConfigKey<Boolean> USE_HTTPS_WINRM = WinRmTool.USE_HTTPS_WINRM;
    public static final ConfigKey<String> WAIT_WINDOWS_TO_START = newStringConfigKey("waitWindowsToStart",
            "By default Brooklyn will return the machine immediately after Brooklyn is able to WinRM. Sometimes restart could happen after a Windows VM is provisioned. This could be because of System Upgrade or other." +
            " By setting this config key to 60s, 5m or other X Duration of time Brooklyn will wait X amount of time for disconnect to occur. If connection failure occurs it will wait X amount of time for the machine to come up.", null);


    /**
     * Flag which tells winrm whether to use Basic Authentication
     * or Negotiate plus NTLM.
     * winrm.useNtlm parameter could be a subject to change.
     * TODO Winrm supports several authentication mechanisms so it would be better to replace it with a prioritised list of authentication mechanisms to try.
     */
    @Beta
    public static final ConfigKey<Boolean> USE_NTLM = WinRmTool.USE_NTLM;

    // TODO merge with {link SshTool#PROP_USER} and {@link SshMachineLocation#user}?
    public static final ConfigKey<String> USER = WinRmTool.PROP_USER;

    // TODO merge with {link SshTool#PROP_PASSWORD}?
    public static final ConfigKey<String> PASSWORD = WinRmTool.PROP_PASSWORD;

    // TODO Delete once winrm4j supports this better?
    public static final ConfigKey<Integer> COPY_FILE_CHUNK_SIZE_BYTES = WinRmTool.COPY_FILE_CHUNK_SIZE_BYTES;

    // Note that SshTool's implementation class *must* use a different key name. Both may be used 
    // within a location's configuration to indicate the implementation to use for WinRmTool and 
    // for SshTool - that will require two different configuration values.
    public static final ConfigKey<String> WINRM_TOOL_CLASS = ConfigKeys.newConfigKeyWithPrefixRemoved(
            BrooklynConfigKeys.BROOKLYN_WINRM_CONFIG_KEY_PREFIX,
            Preconditions.checkNotNull(BrooklynConfigKeys.WINRM_TOOL_CLASS, "static final initializer classload ordering problem"));

    /** 
     * Prefix for config key:values to be passed to the winrm tool on construction. For example, 
     * one could define the location below. When executing winrm commands, it would instantiate
     * an instance of {@code com.acme.brooklyn.MyCustomWinrmTool}, calling its constructor with a
     * {@code Map<String, Object>} that contained the configuration. In this case, the map would
     * include: {@code address=1.2.3.4}; {@code user=myname}; and {@code myparam=myvalue}.
     * 
     * <pre>
     * {@code
     * brooklyn.location.named.myLocation = byon:(hosts=1.2.3.4,user=myname)
     * brooklyn.location.named.myLocation.winrmToolClass = com.acme.brooklyn.MyCustomWinrmTool
     * brooklyn.location.named.myLocation.winrmToolClass.myparam = myvalue
     * }
     * }
     * </pre>
     * <p>
     */
    public static final String WINRM_TOOL_CLASS_PROPERTIES_PREFIX = WINRM_TOOL_CLASS.getName()+".";

    /**
     * @deprecated since 0.9.0; config never read; will be removed in future version.
     */
    @Deprecated
    public static final ConfigKey<Integer> EXECUTION_ATTEMPTS = ConfigKeys.newIntegerConfigKey(
            "windows.exec.attempts",
            "Number of attempts to execute a remote command",
            1);
    
    // TODO See SshTool#PROP_SSH_TRIES, where it was called "sshTries"; remove duplication? Merge into one well-named thing?
    public static final ConfigKey<Integer> EXEC_TRIES = WinRmTool.PROP_EXEC_TRIES;

    @SuppressWarnings("serial")
    public static final ConfigKey<Iterable<String>> PRIVATE_ADDRESSES = ConfigKeys.newConfigKey(
            new TypeToken<Iterable<String>>() {},
            "privateAddresses",
            "Private addresses of this machine, e.g. those within the private network", 
            null);

    @SuppressWarnings("serial")
    public static final ConfigKey<Map<Integer, String>> TCP_PORT_MAPPINGS = ConfigKeys.newConfigKey(
            new TypeToken<Map<Integer, String>>() {},
            "tcpPortMappings",
            "NAT'ed ports, giving the mapping from private TCP port to a public host:port", 
            null);

    public static final Set<HasConfigKey<?>> ALL_WINRM_CONFIG_KEYS =
            ImmutableSet.<HasConfigKey<?>>builder()
                    .addAll(ConfigUtils.getStaticKeysOnClass(WinRmMachineLocation.class))
                    .addAll(ConfigUtils.getStaticKeysOnClass(WinRmTool.class))
                    .build();

    public static final Set<String> ALL_WINRM_CONFIG_KEY_NAMES =
            ImmutableSet.copyOf(Iterables.transform(ALL_WINRM_CONFIG_KEYS, new Function<HasConfigKey<?>,String>() {
                @Override
                public String apply(HasConfigKey<?> input) {
                    return input.getConfigKey().getName();
                }
            }));
    
    @Override
    public void init() {
        super.init();

        // Register any pre-existing port-mappings with the PortForwardManager
        Map<Integer, String> tcpPortMappings = getConfig(TCP_PORT_MAPPINGS);
        if (tcpPortMappings != null) {
            PortForwardManager pfm = (PortForwardManager) getManagementContext().getLocationRegistry().getLocationManaged(PortForwardManagerLocationResolver.PFM_GLOBAL_SPEC);
            for (Map.Entry<Integer, String> entry : tcpPortMappings.entrySet()) {
                int targetPort = entry.getKey();
                HostAndPort publicEndpoint = HostAndPort.fromString(entry.getValue());
                if (!publicEndpoint.hasPort()) {
                    throw new IllegalArgumentException("Invalid portMapping ('"+entry.getValue()+"') for port "+targetPort+" in machine "+this);
                }
                pfm.associate(publicEndpoint.getHostText(), publicEndpoint, this, targetPort);
            }
        }
    }
    
    public String getUser() {
        return config().get(USER);
    }

    @Override
    public InetAddress getAddress() {
        return getConfig(ADDRESS);
    }

    @Override
    public OsDetails getOsDetails() {
        return null;
    }

    @Override
    public MachineDetails getMachineDetails() {
        return null;
    }

    @Nullable
    @Override
    public String getHostname() {
        InetAddress address = getAddress();
        return (address != null) ? address.getHostAddress() : null;
    }

    @Nullable
    protected String getHostAndPort() {
        String host = getHostname();
        return (host == null) ? null : host + ":" + getDefaultPort();
    }

    public int getPort() {
        Maybe<Object> raw = config().getRaw(WinRmTool.PROP_PORT);
        if (raw.orNull() == null && config().getRaw(WINRM_CONFIG_PORT).orNull() != null) {
            return config().get(WINRM_CONFIG_PORT);
        } else {
            Integer result = config().get(WinRmTool.PROP_PORT);
            return (result != null) ? result : getDefaultPort();
        }
    }

    private int getDefaultPort() {
        return getConfig(USE_HTTPS_WINRM) ? 5986 : 5985;
    }

    @Override
    public Set<String> getPublicAddresses() {
        InetAddress address = getAddress();
        return (address == null) ? ImmutableSet.<String>of() : ImmutableSet.of(address.getHostAddress());
    }
    
    @Override
    public Set<String> getPrivateAddresses() {
        Iterable<String> result = getConfig(PRIVATE_ADDRESSES);
        return (result == null) ? ImmutableSet.<String>of() : ImmutableSet.copyOf(result);
    }

    /**
     * @deprecated since 0.9.0; use {@link #executeCommand(String)}
     */
    @Deprecated
    public WinRmToolResponse executeScript(String script) {
        return executeCommand(script);
    }

    /**
     * @deprecated since 0.9.0; use {@link #executeCommand(List)}
     */
    @Deprecated
    public WinRmToolResponse executeScript(List<String> script) {
        return executeCommand(script);
    }
    
    /**
     * @deprecated since 0.9.0; use {@link #executeCommand(Map, List)}
     */
    @Deprecated
    public WinRmToolResponse executeScript(Map<?,?> props, List<String> script) {
        return executeCommand(props, script);
    }

    /**
     * @since 0.9.0 (previously was {@code executeScript(String)}
     */
    public WinRmToolResponse executeCommand(String script) {
        return executeCommand(ImmutableList.of(script));
    }

    /**
     * @since 0.9.0 (previously was {@code executeScript(List)}
     */
    public WinRmToolResponse executeCommand(List<String> script) {
        return executeCommand(ImmutableMap.of(), script);
    }
    
    /**
     * @since 0.9.0 (previously was {@code executeScript(Map, List)}
     */
    public WinRmToolResponse executeCommand(Map<?,?> props, List<String> script) {
        WinRmTool tool = newWinRmTool(props);
        return tool.executeCommand(script);
    }

    public WinRmToolResponse executePsScript(String psScript) {
        return executePsScript(ImmutableMap.of(), ImmutableList.of(psScript));
    }

    public WinRmToolResponse executePsScript(List<String> psScript) {
        return executePsScript(ImmutableMap.of(), psScript);
    }
    
    public WinRmToolResponse executePsScript(Map<?,?> props, List<String> psScript) {
        WinRmTool tool = newWinRmTool(props);
        return tool.executePs(psScript);
    }

    protected WinRmTool newWinRmTool(Map<?,?> props) {
        // TODO See comments/TODOs in SshMachineLocation.connectSsh()
        try {
            ConfigBag args = new ConfigBag();

            for (Map.Entry<ConfigKey<?>, ?> entry: config().getBag().getAllConfigAsConfigKeyMap().entrySet()) {
    
                boolean include = false;
                String keyName = entry.getKey().getName();
                if (keyName.startsWith(WinRmTool.BROOKLYN_CONFIG_KEY_PREFIX)) {
                    keyName = Strings.removeFromStart(keyName, WinRmTool.BROOKLYN_CONFIG_KEY_PREFIX);
                    include = true;
                }
                
                if (keyName.startsWith(WINRM_TOOL_CLASS_PROPERTIES_PREFIX)) {
                    keyName = Strings.removeFromStart(keyName, WINRM_TOOL_CLASS_PROPERTIES_PREFIX);
                    include = true;
                }
                
                if (ALL_WINRM_CONFIG_KEY_NAMES.contains(keyName)) {
                    // key should be included, and does not need to be changed
    
                    // TODO make this config-setting mechanism more universal
                    // currently e.g. it will not admit a tool-specific property.
                    // thinking either we know about the tool here,
                    // or we don't allow unadorned keys to be set
                    // (require use of BROOKLYN_CONFIG_KEY_PREFIX)
                    include = true;
                }

                if (include) {
                    args.putStringKey(keyName, config().get(entry.getKey()));
                }
            }
            
            args.putAll(props);
            args.configure(SshTool.PROP_HOST, getAddress().getHostAddress());
            args.configure(WinRmTool.USE_NTLM, getConfig(WinRmMachineLocation.USE_NTLM));
            args.configure(WinRmTool.PROP_PORT, getPort());

            if (LOG.isTraceEnabled()) LOG.trace("creating WinRM session for "+Sanitizer.sanitize(args));

            // look up tool class
            String toolClass = args.get(WINRM_TOOL_CLASS);
            if (toolClass == null) toolClass = Winrm4jTool.class.getName();
            WinRmTool tool = (WinRmTool) new ClassLoaderUtils(this, getManagementContext()).loadClass(toolClass).getConstructor(Map.class).newInstance(args.getAllConfig());
            if (tool instanceof ManagementContextInjectable) {
                ((ManagementContextInjectable)tool).setManagementContext(getManagementContext());
            }

            if (LOG.isTraceEnabled()) LOG.trace("using ssh-tool {} (of type {}); props ", tool, toolClass);

            return tool;
        } catch (Exception e) {
            throw Exceptions.propagate(e);
        }
    }

    public int copyTo(File source, String destination) {
        FileInputStream sourceStream = null;
        try {
            sourceStream = new FileInputStream(source);
            return copyTo(sourceStream, destination);
        } catch (FileNotFoundException e) {
            throw Exceptions.propagate(e);
        } finally {
            if (sourceStream != null) {
                Streams.closeQuietly(sourceStream);
            }
        }
    }

    public int copyTo(InputStream source, String destination) {
        return copyTo(ImmutableMap.of(), source, destination);
    }
    
    public int copyTo(Map<?,?> props, InputStream source, String destination) {
        WinRmTool tool = newWinRmTool(props);
        WinRmToolResponse response = tool.copyToServer(source, destination);
        return response.getStatusCode();
    }

    public static String getDefaultUserMetadataString(ConfigurationSupportInternal config) {
        // Using an encoded command obviates the need to escape
        String unencodePowershell = Joiner.on("\r\n").join(ImmutableList.of(
                // Allow TS connections
                "$RDP = Get-WmiObject -Class Win32_TerminalServiceSetting -ComputerName $env:computername -Namespace root\\CIMV2\\TerminalServices -Authentication PacketPrivacy",
                "$RDP.SetAllowTSConnections(1,1)",
                "Set-ExecutionPolicy Unrestricted -Force",
                // Set unlimited values for remote execution limits
                "Set-Item WSMan:\\localhost\\Shell\\MaxConcurrentUsers 100",
                "Set-Item WSMan:\\localhost\\Shell\\MaxMemoryPerShellMB 0",
                "Set-Item WSMan:\\localhost\\Shell\\MaxProcessesPerShell 0",
                "Set-Item WSMan:\\localhost\\Shell\\MaxShellsPerUser 0",
                "New-ItemProperty \"HKLM:\\System\\CurrentControlSet\\Control\\LSA\" -Name \"SuppressExtendedProtection\" -Value 1 -PropertyType \"DWord\"",
                // The following allows scripts to re-authenticate with local credential - this is required
                // as certain operations cannot be performed with remote credentials
                "$allowed = @('WSMAN/*')",
                "$key = 'hklm:\\SOFTWARE\\Policies\\Microsoft\\Windows\\CredentialsDelegation'",
                "if (!(Test-Path $key)) {",
                "    md $key",
                "}",
                "New-ItemProperty -Path $key -Name AllowFreshCredentials -Value 1 -PropertyType Dword -Force",
                "New-ItemProperty -Path $key -Name AllowFreshCredentialsWhenNTLMOnly -Value 1 -PropertyType Dword -Force",
                "$credKey = Join-Path $key 'AllowFreshCredentials'",
                "if (!(Test-Path $credKey)) {",
                "    md $credkey",
                "}",
                "$ntlmKey = Join-Path $key 'AllowFreshCredentialsWhenNTLMOnly'",
                "if (!(Test-Path $ntlmKey)) {",
                "    md $ntlmKey",
                "}",
                "$i = 1",
                "$allowed |% {",
                "    # Script does not take into account existing entries in this key",
                "    New-ItemProperty -Path $credKey -Name $i -Value $_ -PropertyType String -Force",
                "    New-ItemProperty -Path $ntlmKey -Name $i -Value $_ -PropertyType String -Force",
                "    $i++",
                "}"
        ));

        // FIXME USE_HTTPS_WINRM
        // Missing generate certificate step.
        // https://support.microsoft.com/en-us/kb/2019527
        //
        // One possible approach is to generate a certificate and append it to this command.
        // http://stackoverflow.com/questions/1615871/creating-an-x509-certificate-in-java-without-bouncycastle
        //
        // @neykov:
        // The certificate is best generated on the machine, without leaving it ever (for self-signed case). On the other hand it's not possible to get the public part at this step.
        // I see this setup step dissappearing longer term (or keeping it minimalistic).
        // Instead do something like the jclouds init sequence where it connects with whatever is provided by the cloud, then configuring it to our liking.
        boolean useSecureWinrm = config.getBag().get(USE_HTTPS_WINRM);

        boolean basicAuth = !config.getBag().get(USE_NTLM), allowUnencrypted = !useSecureWinrm;
        int port = useSecureWinrm ? 5986 : 5985;

        String encoded = new String(Base64.encodeBase64(unencodePowershell.getBytes(Charsets.UTF_16LE)));
        return String.format("winrm quickconfig -q & " +
                "winrm set winrm/config/service/auth @{Basic=\"%1$s\"} & " +
                "winrm set winrm/config/service @{AllowUnencrypted=\"%2$s\"} & " +
                "winrm set winrm/config/winrs @{MaxConcurrentUsers=\"100\"} & " +
                "winrm set winrm/config/winrs @{MaxMemoryPerShellMB=\"0\"} & " +
                "winrm set winrm/config/winrs @{MaxProcessesPerShell=\"0\"} & " +
                "winrm set winrm/config/winrs @{MaxShellsPerUser=\"0\"} & " +
                "netsh advfirewall firewall add rule name=RDP dir=in protocol=tcp localport=3389 action=allow profile=any & " +
                "netsh advfirewall firewall add rule name=WinRM dir=in protocol=tcp localport=%3$d action=allow profile=any & " +
                "powershell -EncodedCommand ",
                basicAuth,
                allowUnencrypted,
                port
                )
                + encoded;
        /* TODO: Find out why scripts with new line characters aren't working on AWS. The following appears as if it *should*
           work but doesn't - the script simply isn't run. By connecting to the machine via RDP, you can get the script
           from 'http://169.254.169.254/latest/user-data', and running it at the command prompt works, but for some
           reason the script isn't run when the VM is provisioned
        */
//        return Joiner.on("\r\n").join(ImmutableList.of(
//                "winrm quickconfig -q",
//                "winrm set winrm/config/service/auth @{Basic=\"true\"}",
//                "winrm set winrm/config/client @{AllowUnencrypted=\"true\"}",
//                "winrm set winrm/config/service @{AllowUnencrypted=\"true\"}",
//                "netsh advfirewall firewall add rule name=RDP dir=in protocol=tcp localport=3389 action=allow profile=any",
//                "netsh advfirewall firewall add rule name=WinRM dir=in protocol=tcp localport=5985 action=allow profile=any",
//                // Using an encoded command necessitates the need to escape. The unencoded command is as follows:
//                // $RDP = Get-WmiObject -Class Win32_TerminalServiceSetting -ComputerName $env:computername -Namespace root\CIMV2\TerminalServices -Authentication PacketPrivacy
//                // $Result = $RDP.SetAllowTSConnections(1,1)
//                "powershell -EncodedCommand JABSAEQAUAAgAD0AIABHAGUAdAAtAFcAbQBpAE8AYgBqAGUAYwB0ACAALQBDAGwAYQBzAHMAI" +
//                        "ABXAGkAbgAzADIAXwBUAGUAcgBtAGkAbgBhAGwAUwBlAHIAdgBpAGMAZQBTAGUAdAB0AGkAbgBnACAALQBDAG8AbQBwA" +
//                        "HUAdABlAHIATgBhAG0AZQAgACQAZQBuAHYAOgBjAG8AbQBwAHUAdABlAHIAbgBhAG0AZQAgAC0ATgBhAG0AZQBzAHAAY" +
//                        "QBjAGUAIAByAG8AbwB0AFwAQwBJAE0AVgAyAFwAVABlAHIAbQBpAG4AYQBsAFMAZQByAHYAaQBjAGUAcwAgAC0AQQB1A" +
//                        "HQAaABlAG4AdABpAGMAYQB0AGkAbwBuACAAUABhAGMAawBlAHQAUAByAGkAdgBhAGMAeQANAAoAJABSAGUAcwB1AGwAd" +
//                        "AAgAD0AIAAkAFIARABQAC4AUwBlAHQAQQBsAGwAbwB3AFQAUwBDAG8AbgBuAGUAYwB0AGkAbwBuAHMAKAAxACwAMQApAA=="
//        ));
    }
}
