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

import static java.lang.String.format;

import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

import org.apache.brooklyn.api.entity.drivers.downloads.DownloadResolver;
import org.apache.brooklyn.api.location.Location;
import org.apache.brooklyn.api.location.OsDetails;
import org.apache.brooklyn.api.mgmt.ManagementContext;
import org.apache.brooklyn.core.entity.Attributes;
import org.apache.brooklyn.core.entity.EntityInternal;
import org.apache.brooklyn.core.entity.lifecycle.Lifecycle;
import org.apache.brooklyn.entity.proxy.AbstractController;
import org.apache.brooklyn.entity.software.base.AbstractSoftwareProcessSshDriver;
import org.apache.brooklyn.entity.software.base.lifecycle.ScriptHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.brooklyn.location.ssh.SshMachineLocation;
import org.apache.brooklyn.util.collections.MutableMap;
import org.apache.brooklyn.util.core.task.DynamicTasks;
import org.apache.brooklyn.util.core.task.Tasks;
import org.apache.brooklyn.util.core.task.ssh.SshTasks;
import org.apache.brooklyn.util.core.task.ssh.SshTasks.OnFailingTask;
import org.apache.brooklyn.util.exceptions.Exceptions;
import org.apache.brooklyn.util.net.Networking;
import org.apache.brooklyn.util.ssh.BashCommands;
import org.apache.brooklyn.util.stream.Streams;
import org.apache.brooklyn.util.text.Strings;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Lists;

/**
 * Start a {@link NginxController} in a {@link Location} accessible over ssh.
 */
public class NginxSshDriver extends AbstractSoftwareProcessSshDriver implements NginxDriver {

    // TODO An alternative way of installing nginx is described at:
    //   http://sjp.co.nz/posts/building-nginx-for-debian-systems/
    // It's use of `apt-get source nginx` and `apt-get build-dep nginx` makes
    // it look higher level and therefore more appealing.

    public static final Logger log = LoggerFactory.getLogger(NginxSshDriver.class);
    public static final String NGINX_PID_FILE = "logs/nginx.pid";

    private boolean customizationCompleted = false;

    public NginxSshDriver(NginxControllerImpl entity, SshMachineLocation machine) {
        super(entity, machine);

        entity.sensors().set(Attributes.LOG_FILE_LOCATION, getLogFileLocation());
        entity.sensors().set(NginxController.ACCESS_LOG_LOCATION, getAccessLogLocation());
        entity.sensors().set(NginxController.ERROR_LOG_LOCATION, getErrorLogLocation());
    }

    @Override
    public NginxControllerImpl getEntity() {
        return (NginxControllerImpl) super.getEntity();
    }

    public String getLogFileLocation() {
        return format("%s/console", getRunDir());
    }

    public String getAccessLogLocation() {
        String accessLog = entity.getConfig(NginxController.ACCESS_LOG_LOCATION);
        return format("%s/%s", getRunDir(), accessLog);
    }

    public String getErrorLogLocation() {
        String errorLog = entity.getConfig(NginxController.ERROR_LOG_LOCATION);
        return format("%s/%s", getRunDir(), errorLog);
    }

    /** By default Nginx writes the pid of the master process to {@code logs/nginx.pid} */
    @Override
    public String getPidFile() {
        return format("%s/%s", getRunDir(), NGINX_PID_FILE);
    }

    @Deprecated /** @deprecated since 0.7.0 use #getPort */
    public Integer getHttpPort() {
        return getEntity().getPort();
    }

    public Integer getPort() {
        return getEntity().getPort();
    }

    @Override
    public void rebind() {
        customizationCompleted = true;
    }

    @Override
    public void postLaunch() {
        entity.sensors().set(NginxController.PID_FILE, getRunDir() + "/" + AbstractSoftwareProcessSshDriver.PID_FILENAME);
        if (((AbstractController)entity).isSsl()) {
            entity.sensors().set(Attributes.HTTPS_PORT, getPort());
            ((EntityInternal)entity).sensors().remove(Attributes.HTTP_PORT);
        } else {
            entity.sensors().set(Attributes.HTTP_PORT, getPort());
            ((EntityInternal)entity).sensors().remove(Attributes.HTTPS_PORT);
        }
        super.postLaunch();
    }

    @Override
    public void install() {
        // inessential here, installation will fail later if it needs to sudo (eg if using port 80)
        DynamicTasks.queueIfPossible(SshTasks.dontRequireTtyForSudo(getMachine(), OnFailingTask.WARN_OR_IF_DYNAMIC_FAIL_MARKING_INESSENTIAL)).orSubmitAndBlock();

        List<String> nginxUrls = resolver.getTargets();
        String nginxSaveAs = resolver.getFilename();

        boolean sticky = ((NginxController) entity).isSticky();
        boolean isMac = getMachine().getOsDetails().isMac();

        MutableMap<String, String> installGccPackageFlags = MutableMap.of(
                "onlyifmissing", "gcc",
                "yum", "gcc",
                "apt", "gcc",
                "zypper", "gcc",
                "port", null);
        MutableMap<String, String> installMakePackageFlags = MutableMap.of(
                "onlyifmissing", "make",
                "yum", "make",
                "apt", "make",
                "zypper", "make",
                "port", null);
        MutableMap<String, String> installPackageFlags = MutableMap.of(
                "yum", "openssl-devel pcre-devel",
                "apt", "libssl-dev zlib1g-dev libpcre3-dev",
                "zypper", "libopenssl-devel pcre-devel",
                "port", null);

        String stickyModuleVersion = entity.getConfig(NginxController.STICKY_VERSION);
        DownloadResolver stickyModuleResolver = mgmt().getEntityDownloadsManager().newDownloader(
                this, "stickymodule", ImmutableMap.of("addonversion", stickyModuleVersion));
        List<String> stickyModuleUrls = stickyModuleResolver.getTargets();
        String stickyModuleSaveAs = stickyModuleResolver.getFilename();
        String stickyModuleExpandedInstallDir = String.format("%s/src/%s", getExpandedInstallDir(),
                stickyModuleResolver.getUnpackedDirectoryName("nginx-sticky-module-"+stickyModuleVersion));

        List<String> cmds = Lists.newArrayList();

        cmds.add(BashCommands.ifExecutableElse0("yum", BashCommands.sudo("yum -y install kernel-headers --disableexcludes=all")));
        cmds.add(BashCommands.INSTALL_TAR);
        cmds.add(BashCommands.alternatives(
                BashCommands.ifExecutableElse0("apt-get", BashCommands.installPackage("build-essential")),
                BashCommands.ifExecutableElse0("yum", BashCommands.sudo("yum -y --nogpgcheck groupinstall \"Development Tools\""))));
        cmds.add(BashCommands.installPackage(installGccPackageFlags, "nginx-prerequisites-gcc"));
        cmds.add(BashCommands.installPackage(installMakePackageFlags, "nginx-prerequisites-make"));
        cmds.add(BashCommands.installPackage(installPackageFlags, "nginx-prerequisites"));
        cmds.addAll(BashCommands.commandsToDownloadUrlsAs(nginxUrls, nginxSaveAs));

        String pcreExpandedInstallDirname = "";
        if (isMac) {
            String pcreVersion = entity.getConfig(NginxController.PCRE_VERSION);
            DownloadResolver pcreResolver = mgmt().getEntityDownloadsManager().newDownloader(
                    this, "pcre", ImmutableMap.of("addonversion", pcreVersion));
            List<String> pcreUrls = pcreResolver.getTargets();
            String pcreSaveAs = pcreResolver.getFilename();
            pcreExpandedInstallDirname = pcreResolver.getUnpackedDirectoryName("pcre-"+pcreVersion);

            // Install PCRE
            cmds.addAll(BashCommands.commandsToDownloadUrlsAs(pcreUrls, pcreSaveAs));
            cmds.add(format("mkdir -p %s/pcre-dist", getInstallDir()));
            cmds.add(format("tar xvzf %s", pcreSaveAs));
            cmds.add(format("cd %s", pcreExpandedInstallDirname));
            cmds.add(format("./configure --prefix=%s/pcre-dist", getInstallDir()));
            cmds.add("make");
            cmds.add("make install");
            cmds.add("cd ..");
        }

        cmds.add(format("tar xvzf %s", nginxSaveAs));
        cmds.add(format("cd %s", getExpandedInstallDir()));

        if (sticky) {
            // Latest versions of sticky module expand to a different folder than the file name.
            // Extract to folder set by us so we know where the sources are.
            cmds.add(format("mkdir -p %s", stickyModuleExpandedInstallDir));
            cmds.add(format("pushd %s", stickyModuleExpandedInstallDir));
            cmds.addAll(BashCommands.commandsToDownloadUrlsAs(stickyModuleUrls, stickyModuleSaveAs));
            cmds.add(format("tar --strip-component=1 -xvzf %s", stickyModuleSaveAs));
            cmds.add("popd");
        }

        // Note that for OS X, not including space after "-L" because broken in 10.6.8 (but fixed in 10.7.x)
        //      see http://trac.nginx.org/nginx/ticket/227
        String withLdOpt = entity.getConfig(NginxController.WITH_LD_OPT);
        if (isMac) withLdOpt = format("-L%s/pcre-dist/lib", getInstallDir()) + (Strings.isBlank(withLdOpt) ? "" : " " + withLdOpt);
        String withCcOpt = entity.getConfig(NginxController.WITH_CC_OPT);
        
        if (isMac) {
            // TODO Upgrade sticky module as soon as a fix for https://bitbucket.org/nginx-goodies/nginx-sticky-module-ng/issue/16/can-not-compile-on-macosx-yosemite
            // is released and remove this block.
            withCcOpt = (Strings.isBlank(withCcOpt) ? "" : (withCcOpt + " ")) + "-Wno-error";
        }

        StringBuilder configureCommand = new StringBuilder("./configure")
                .append(format(" --prefix=%s/dist", getExpandedInstallDir()))
                .append(" --with-http_ssl_module")
                .append(sticky ? format(" --add-module=%s ", stickyModuleExpandedInstallDir) : "")
                .append(!Strings.isBlank(withLdOpt) ? format(" --with-ld-opt=\"%s\"", withLdOpt) : "")
                .append(!Strings.isBlank(withCcOpt) ? format(" --with-cc-opt=\"%s\"", withCcOpt) : "")
                ;
        if (isMac) {
            configureCommand.append(" --with-pcre=")
                    .append(getInstallDir()).append("/").append(pcreExpandedInstallDirname);
        }

        cmds.addAll(ImmutableList.of(
                "mkdir -p dist",
                configureCommand.toString(),
                "make install"));

        ScriptHelper script = newScript(INSTALLING)
                .body.append(cmds)
                .header.prepend("set -x")
                .gatherOutput()
                .failOnNonZeroResultCode(false);

        int result = script.execute();

        if (result != 0) {
            String notes = "likely an error building nginx. consult the brooklyn log ssh output for further details.\n"+
                    "note that this Brooklyn nginx driver compiles nginx from source. " +
                    "it attempts to install common prerequisites but this does not always succeed.\n";
            OsDetails os = getMachine().getOsDetails();
            if (os.isMac()) {
                notes += "deploying to Mac OS X, you will require Xcode and Xcode command-line tools, and on " +
                        "some versions the pcre library (e.g. using macports, sudo port install pcre).\n";
            }
            if (os.isWindows()) {
                notes += "this nginx driver is not designed for windows, unless cygwin is installed, and you are patient.\n";
            }
            if (getEntity().getApplication().getClass().getCanonicalName().startsWith("brooklyn.demo.")) {
                // this is maybe naughty ... but since we use nginx in the first demo example,
                // and since it's actually pretty complicated, let's give a little extra hand-holding
                notes +=
                        "if debugging this is all a bit much and you just want to run a demo, " +
                        "you have two fairly friendly options.\n" +
                        "1. you can use a well known cloud, like AWS or Rackspace, where this should run " +
                        "in a tried-and-tested Ubuntu or CentOS environment, without any problems " +
                        "(and if it does let us know and we'll fix it!).\n"+
                        "2. or you can just use the demo without nginx, instead access the appserver instances directly.\n";
            }

            if (!script.getResultStderr().isEmpty()) {
                notes += "\n" + "STDERR\n" + script.getResultStderr()+"\n";
                Streams.logStreamTail(log, "STDERR of problem in "+Tasks.current(), Streams.byteArrayOfString(script.getResultStderr()), 1024);
            }
            if (!script.getResultStdout().isEmpty()) {
                notes += "\n" + "STDOUT\n" + script.getResultStdout()+"\n";
                Streams.logStreamTail(log, "STDOUT of problem in "+Tasks.current(), Streams.byteArrayOfString(script.getResultStdout()), 1024);
            }

            Tasks.setExtraStatusDetails(notes.trim());

            throw new IllegalStateException("Installation of nginx failed (shell returned non-zero result "+result+")");
        }
    }

    private ManagementContext mgmt() {
        return ((EntityInternal) entity).getManagementContext();
    }

    @Override
    public void customize() {
        newScript(CUSTOMIZING)
                .body.append(
                        format("mkdir -p %s", getRunDir()),
                        format("cp -R %s/dist/{conf,html,logs,sbin} %s", getExpandedInstallDir(), getRunDir()))
                .execute();

        // Install static content archive, if specified
        String archiveUrl = entity.getConfig(NginxController.STATIC_CONTENT_ARCHIVE_URL);
        if (Strings.isNonBlank(archiveUrl)) {
            getEntity().deploy(archiveUrl);
        }

        customizationCompleted = true;
    }

    @Override
    public boolean isCustomizationCompleted() {
        return customizationCompleted;
    }

    @Override
    public void launch() {
        // TODO if can't be root, and ports > 1024 are in the allowed port range,
        // prefer that; could do this on SshMachineLocation which implements PortSupplier,
        // invoked from PortAttrSensorAndConfigKey, which is invoked from MachineLifecycleTasks.preStartCustom
        Networking.checkPortsValid(MutableMap.of("port", getPort()));

        getEntity().doExtraConfigurationDuringStart();

        // We wait for evidence of running because, using SshCliTool,
        // we saw the ssh session return before the tomcat process was fully running
        // so the process failed to start.
        newScript(MutableMap.of("usePidFile", false), LAUNCHING)
                .body.append(
                        format("cd %s", getRunDir()),
                        BashCommands.requireExecutable("./sbin/nginx"),
                        sudoBashCIfPrivilegedPort(getPort(), format(
                                "nohup ./sbin/nginx -p %s/ -c conf/server.conf > %s 2>&1 &", getRunDir(), getLogFileLocation())),
                        format("for i in {1..10}\n" +
                                "do\n" +
                                "    test -f %1$s && ps -p `cat %1$s` && exit\n" +
                                "    sleep 1\n" +
                                "done\n" +
                                "echo \"No explicit error launching nginx but couldn't find process by pid; continuing but may subsequently fail\"\n" +
                                "cat %2$s | tee /dev/stderr",
                                getPidFile(), getLogFileLocation()))
                .execute();
    }

    public static String sudoIfPrivilegedPort(int port, String command) {
        return port < 1024 ? BashCommands.sudo(command) : command;
    }

    public static String sudoBashCIfPrivilegedPort(int port, String command) {
        return port < 1024 ? BashCommands.sudo("bash -c '"+command+"'") : command;
    }

    @Override
    public boolean isRunning() {
        return newScript(MutableMap.of("usePidFile", getPidFile()), CHECK_RUNNING).execute() == 0;
    }

    @Override
    public void stop() {
        // Don't `kill -9`, as that doesn't stop the worker processes
        newScript(MutableMap.of("usePidFile", false), STOPPING).
                body.append(
                        format("cd %s", getRunDir()),
                        format("export PID=`cat %s`", getPidFile()),
                        "test -n \"$PID\" || exit 0",
                        sudoIfPrivilegedPort(getPort(), "kill $PID"))
                .execute();
    }

    @Override
    public void kill() {
        stop();
    }

    private final ExecController reloadExecutor = new ExecController(
            entity+"->reload",
            new Runnable() {
                @Override
                public void run() {
                    reloadImpl();
                }
            });

    public void reload() {
        // If there are concurrent calls to reload (such that some calls come in when another call is queued), then
        // don't bother doing the subsequent calls. Instead just rely on the currently queued call.
        //
        // Motivation is that calls to nginx.reload were backing up: we ended up executing lots of them in parallel
        // when there were several changes to the nginx conifg that requiring a reload. The problem can be particularly
        // bad because the ssh commands take a second or two - if 10 changes were made to the config in that time, we'd
        // end up executing reload 10 times in parallel.

        reloadExecutor.run();
    }

    private void reloadImpl() {
        // Note that previously, if serviceUp==false then we'd restart nginx.
        // That caused a race on stop()+reload(): nginx could simultaneously be stopping and also reconfiguring
        // (e.g. due to a cluster-resize), the restart() would leave nginx running even after stop() had returned.
        //
        // Now we rely on NginxController always calling update (and thus reload) once it has started. This is
        // done in AbstractController.postActivation().
        //
        // If our blocking check sees that !isRunning() (and if a separate thread is starting it, and subsequently
        // calling waitForEntityStart()), we can guarantee that the start-thread's call to update will happen after
        // this call to reload. So we this can be a no-op, and just rely on that subsequent call to update.

        Lifecycle lifecycle = entity.getAttribute(NginxController.SERVICE_STATE_ACTUAL);
        if (lifecycle==Lifecycle.STOPPING || lifecycle==Lifecycle.STOPPED || !isRunning()) {
            log.debug("Ignoring reload of nginx "+entity+", because service is not running (state "+lifecycle+")");
            return;
        }

        doReloadNow();
    }

    /**
     * Instructs nginx to reload its configuration (without restarting, so don't lose any requests).
     * Can be overridden if necessary, to change the call used for reloading.
     */
    private void doReloadNow() {
        // We use kill -HUP because that is recommended at http://wiki.nginx.org/CommandLine,
        // but there is no noticeable difference (i.e. no impact on #365) compared to:
        //   sudoIfPrivilegedPort(getHttpPort(), format("./sbin/nginx -p %s/ -c conf/server.conf -s reload", getRunDir()))
        //
        // Note that if conf file is invalid, you'll get no stdout/stderr from `kill` but you
        // do from using `nginx ... -s reload` so that can be handy when manually debugging.

        log.debug("reloading nginx by simularing restart (kill -HUP) - {}", entity);
        newScript(RESTARTING)
                .body.append(
                        format("cd %s", getRunDir()),
                        format("export PID=`cat %s`", getPidFile()),
                        sudoIfPrivilegedPort(getPort(), "kill -HUP $PID"))
                .execute();
    }

    /**
     * Executes the given task, but only if another thread hasn't executed it for us (where the other thread
     * began executing it after the current caller of {@link #run()} began attempting to do so itself).
     *
     * @author aled
     */
    private static class ExecController {
        private final String summary;
        private final Runnable task;
        private final AtomicLong counter = new AtomicLong();

        ExecController(String summary, Runnable task) {
            this.summary = summary;
            this.task = task;
        }

        void run() {
            long preCount = counter.get();
            synchronized (this) {
                if (counter.compareAndSet(preCount, preCount+1)) {
                    try {
                        if (log.isDebugEnabled()) log.debug("Executing {}; incremented count to {}", new Object[] {summary, counter});
                        task.run();
                    } catch (Exception e) {
                        if (log.isDebugEnabled()) log.debug("Failed executing {}; reseting count to {} and propagating exception: {}", new Object[] {summary, preCount, e});
                        counter.set(preCount);
                        throw Exceptions.propagate(e);
                    }
                } else {
                    if (log.isDebugEnabled()) log.debug("Not executing {} because executed by another thread subsequent to us attempting (preCount {}; count {})", new Object[] {summary, preCount, counter});
                }
            }
        }
    }
}
