package com.atlassian.bitbucket.mesh.process.nu;

import com.atlassian.bitbucket.mesh.process.OsProcess;
import com.zaxxer.nuprocess.NuProcess;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import java.nio.ByteBuffer;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import static java.util.Objects.requireNonNull;

class NuOsProcess implements OsProcess {

    private static final Logger log = LoggerFactory.getLogger(NuOsProcess.class);

    private final String commandLine;
    private final NuProcess process;
    private final AtomicBoolean stdinClosed;

    //cancel()/isCanceled() may be called from threads which aren't the one running the process
    //handlers, so both of these fields need to be volatile
    private volatile boolean canceled;
    private volatile boolean destroyed;

    public NuOsProcess(@Nonnull NuProcess process, @Nonnull String commandLine) {
        this.commandLine = requireNonNull(commandLine, "commandLine");
        this.process = requireNonNull(process, "process");

        stdinClosed = new AtomicBoolean();
    }

    @Override
    public void cancel() {
        canceled = true;

        destroyIfRunning(); //If cancel() is called multiple times, this will switch to a SIGKILL
    }

    @Override
    public void closeStdin(boolean force) {
        //Forced closes are always forwarded to the process, but "normal" closes are only forwarded
        //once, because NuProcess throws an exception if stdin is closed multiple times
        if (stdinClosed.compareAndSet(false, true) || force) {
            try {
                process.closeStdin(force);
            } catch (RuntimeException e) {
                //Unfortunately, checking isRunning() here is unlikely to be useful. closeStdin is
                //_generally_ (not exclusively, but generally) called from inside a process handler
                //callback, and NuProcess can only be in one callback at a time. That's relevant to
                //isRunning() because NuProcess only sets that to false inside onExit. isRunning()
                //_does not_ actually check whether the process is running or not; it returns the
                //last known state based on NuProcess's own tracking
                log.debug("{}: Failed to close stdin; it may already be closed", process.getPID(), e);
            }
        }
    }

    @Override
    public boolean hasPendingWrites() {
        return process.hasPendingWrites();
    }

    @Override
    public boolean isCanceled() {
        return canceled;
    }

    @Override
    public boolean isRunning() {
        return process.isRunning();
    }

    @Override
    public void wantWrite() {
        if (canceled) {
            log.debug("{}: Dropping wantWrite(); the process has been canceled", process.getPID());
        } else if (process.isRunning()) {
            process.wantWrite();
        } else {
            log.debug("{}: Dropping wantWrite(); the process is not running", process.getPID());
        }
    }

    @Override
    public void writeStdin(@Nonnull ByteBuffer buffer) {
        process.writeStdin(buffer);
    }

    @Override
    public String toString() {
        return commandLine;
    }

    /**
     * Calls {@link NuProcess#destroy} if the process is {@link NuProcess#isRunning running}. Attempts to shutdown
     * the process gracefully using {@code SIGTERM} the first time it is called, then switches to {@code SIGKILL}
     * for any subsequent calls.
     * <p>
     * Unlike {@link #terminateIfRunning()}, this method <i>never</i> waits for the process to actually stop.
     *
     * @see #terminateIfRunning()
     */
    void destroyIfRunning() {
        if (process.isRunning()) {
            try {
                //Some processes may block reading from stdin and, at least on Windows, it appears there
                //are situations where such a process _does not stop_ when destroy(...) is called. To try
                //and avoid hanging, anytime a process is destroyed we first close stdin.
                //Note: This passes "true" because otherwise closeStdin can throw an exception if it has
                //      previously been called. Forced closing doesn't throw on multiple calls
                process.closeStdin(true);
            } finally {
                //The first time we destroy the process, we do it gracefully. If we end up here a second
                //time, or beyond, we kill the process forcefully. On Windows this makes no difference,
                //but on Linux and macOS this switches between SIGTERM and SIGKILL
                process.destroy(destroyed);
                destroyed = true;
            }
        }
    }

    /**
     * @return the process's identifier
     */
    int getPid() {
        return process.getPID();
    }

    /**
     * Attempts to gracefully terminate the process, if it's running, and, the process fails to stop after a
     * short wait, falls back on killing the process.
     * <p>
     * This first tries a {@code SIGTERM} before resorting to a {@code SIGKILL}.
     *
     * @see #destroyIfRunning()
     */
    void terminateIfRunning() {
        if (process.isRunning()) {
            //Start out with a SIGTERM to try and let the process stop gracefully.
            process.destroy(false);

            try {
                process.waitFor(5L, TimeUnit.SECONDS);
                if (process.isRunning()) {
                    //The process didn't respond to SIGTERM, so this time send it a SIGKILL instead.
                    //If the process still doesn't terminate, it's abandoned
                    //TODO: Do we want to implement a background "reaper" for this sort of scenario
                    //      so we can allow the client call to move forward "sooner"?
                    process.destroy(true);
                }
            } catch (InterruptedException e) {
                log.warn("{}: Interrupted while terminating", process.getPID());
                Thread.currentThread().interrupt();
            }
        }
    }
}
