package com.atlassian.bitbucket.scm;

import com.atlassian.utils.process.*;
import com.google.common.base.MoreObjects;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import static java.util.Objects.requireNonNull;

/**
 * Wrapper around Atlassian's process utils.
 */
public abstract class BaseCommand<T> implements Command<T>, AsyncCommand<T> {

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

    private final List<String> arguments;
    private final Map<String, String> environment;
    /**
     * If you override getProcessHandler() you may choose to use this as your error OutputHandler. It will automatically
     * log any output written to the error stream to the debug logs after the command executes.
     */
    private final OutputHandler errorHandler;
    private final CommandExitHandler exitHandler;
    private final InputHandler inputHandler;
    private final OutputHandler outputHandler;
    private final File workingDir;

    private Long executionTimeoutInSeconds;
    private Long idleTimeoutInSeconds;
    private ExternalProcess process;
    private ProcessHandler processHandler;
    private volatile boolean startable;

    protected BaseCommand(@Nonnull List<String> arguments, @Nullable Map<String, String> environment,
                          @Nullable File workingDir, @Nullable CommandExitHandler exitHandler,
                          @Nullable CommandInputHandler inputHandler, @Nullable CommandOutputHandler<T> outputHandler,
                          @Nullable CommandErrorHandler errorHandler) {
        this.arguments = new ArrayList<>(requireNonNull(arguments, "arguments"));              //Ensure mutable
        this.environment = environment == null ? new HashMap<>() : new HashMap<>(environment); //Ensure mutable, not null
        this.errorHandler = MoreObjects.firstNonNull(errorHandler, new StringOutputHandler("UTF-8"));
        this.exitHandler = exitHandler;
        this.inputHandler = inputHandler;
        this.outputHandler = MoreObjects.firstNonNull(outputHandler, new StringOutputHandler("UTF-8"));
        this.workingDir = workingDir;

        startable = true;
    }

    protected BaseCommand(@Nonnull String binary, @Nullable String command, @Nullable Map<String, String> environment,
                          @Nullable File workingDir, @Nullable CommandExitHandler exitHandler,
                          @Nullable CommandInputHandler inputHandler, @Nullable CommandOutputHandler<T> outputHandler,
                          @Nullable CommandErrorHandler errorHandler) {
        this(mergeArguments(binary, command), environment, workingDir,
                exitHandler, inputHandler, outputHandler, errorHandler);
    }

    @Nonnull
    @Override
    public AsyncCommand<T> asynchronous() {
        return this;
    }

    @Nullable
    @Override
    public T call() {
        Future<T> result = doStart(false);

        try {
            return result.get();
        } catch (InterruptedException | ExecutionException e) {
            callExitHandler();
        }
        return null;
    }

    @Override
    public void setExecutionTimeout(long timeoutInSecs) {
        this.executionTimeoutInSeconds = timeoutInSecs;
    }

    @Override
    public void setIdleTimeout(long timeoutInSecs) {
        this.idleTimeoutInSeconds = timeoutInSecs;
    }

    @Nonnull
    @Override
    public Future<T> start() {
        if (!startable) {
            throw new IllegalStateException("This AsyncCommand has been converted to synchronous. " +
                    "It may no longer be started asynchronously.");
        }

        return doStart(true);
    }

    @Nonnull
    @Override
    public Command<T> synchronous() {
        startable = false;

        return this;
    }

    @Nonnull
    @Override
    public String toString() {
        return StringUtils.join(arguments, " ");
    }

    protected void addArgument(Object... options) {
        for (Object option : options) {
            arguments.add(String.valueOf(option));
        }
    }

    protected void callExitHandler() {
        if (exitHandler != null) {
            String command = toString();
            String stdErr = null;
            if (errorHandler instanceof StringOutputHandler) {
                // if getErrorHandler() is overridden, err output is assumed to be handled, so the stderr String passed
                // to the exitHandler is null
                stdErr = StringUtils.chomp(((StringOutputHandler) errorHandler).getOutput());
            }

            if (process.isCanceled()) {
                log.trace("The process was canceled");
                exitHandler.onCancel(command, processHandler.getExitCode(), stdErr, processHandler.getException());
            } else {
                log.trace("The process completed with exit code {} and stderr of:\n{}", processHandler.getExitCode(), stdErr);
                exitHandler.onExit(command, processHandler.getExitCode(), stdErr, processHandler.getException());
            }
        }
    }

    @Nonnull
    protected OutputHandler getErrorHandler() {
        return errorHandler;
    }

    @Nullable
    protected InputHandler getInputHandler() {
        return inputHandler;
    }

    @Nonnull
    protected OutputHandler getOutputHandler() {
        return outputHandler;
    }

    @Nonnull
    protected ProcessHandler getProcessHandler() {
        if (processHandler == null) {
            processHandler = new SummarizingProcessHandler(getInputHandler(), getOutputHandler(), getErrorHandler());
        }
        return processHandler;
    }

    @Nullable
    protected abstract T getResult();

    private static List<String> mergeArguments(String binary, String command) {
        List<String> arguments = new ArrayList<>(); //Must be mutable
        arguments.add(requireNonNull(binary, "binary"));
        if (StringUtils.isNotBlank(command)) {
            arguments.add(command);
        }

        return arguments;
    }

    private Future<T> doStart(boolean asynchronous) {
        if (log.isTraceEnabled()) {
            log.trace("Starting {} ", toString());
        }

        ExternalProcessBuilder processBuilder = new ExternalProcessBuilder()
                //The "command" list must be mutable; ExternalProcessImpl may change its contents,
                //and it does not create its own mutable copy first
                .command(new ArrayList<>(arguments), workingDir)
                .env(environment)
                .handler(getProcessHandler());
        if (asynchronous) {
            processBuilder.asynchronous();
        }
        if (executionTimeoutInSeconds != null) {
            processBuilder.executionTimeout(TimeUnit.SECONDS.toMillis(executionTimeoutInSeconds));
        }
        if (idleTimeoutInSeconds != null) {
            processBuilder.idleTimeout(TimeUnit.SECONDS.toMillis(idleTimeoutInSeconds));
        }

        process = processBuilder.build();
        process.start();

        return new CommandFuture();
    }

    private class CommandFuture implements Future<T> {

        private volatile boolean done;
        private T result;

        public CommandFuture() {
        }

        @Override
        public boolean cancel(boolean interruptAllowed) {
            if (process.getHandler().isComplete() || isCancelled()) {
                return false;
            }
            log.debug("Canceling process '{}'", process.getCommandLine());
            process.cancel();

            // give the process a max of 100ms to wrap up
            return process.finish(100);
        }

        @Override
        public T get() {
            if (done) {
                return result;
            }
            process.finish();

            return internalGet();
        }

        @Override
        public T get(long l, @Nonnull TimeUnit timeUnit) throws TimeoutException {
            if (done) {
                return result;
            }

            int timeoutMs = (int) timeUnit.toMillis(l);
            if (!process.finish(timeoutMs)) {
                throw new TimeoutException("Timed out while waiting for " + process.getCommandLine() + " to finish");
            }
            return internalGet();
        }

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

        @Override
        public boolean isDone() {
            return done;
        }

        private T internalGet() {
            if (!done) {
                done = true;

                log.debug("Executed {}", BaseCommand.this);
                callExitHandler();

                result = getResult();
            }
            return result;
        }
    }
}
