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

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import org.apache.jorphan.util.JOrphanUtils;

/**
 * Utility class for invoking native system applications
 */
public class SystemCommand {
    /**
     * @deprecated does not make sense anymore. Superceded by {@link Process#wait(long)}
     */
    @Deprecated
    public static final int POLL_INTERVAL = 100;
    private final File directory;
    private final Map<String, String> env;
    private Map<String, String> executionEnvironment;
    private final InputStream stdin;
    private final OutputStream stdout;
    private final boolean stdoutWasNull;
    private final OutputStream stderr;
    private final long timeoutMillis;
    private final int pollInterval;

    /**
     * @param env Environment variables appended to environment (may be null)
     * @param directory File working directory (may be null)
     */
    public SystemCommand(File directory, Map<String, String> env) {
        this(directory, 0L, 0, env, (InputStream) null, (OutputStream) null, (OutputStream) null);
    }

    /**
     *
     * @param env Environment variables appended to environment (may be null)
     * @param directory File working directory (may be null)
     * @param timeoutMillis timeout in Milliseconds
     * @param pollInterval Value used to poll for Process execution end
     * @param stdin File name that will contain data to be input to process (may be null)
     * @param stdout File name that will contain out stream (may be null)
     * @param stderr File name that will contain err stream (may be null)
     * @throws IOException if the input file is not found or output cannot be written
     */
    public SystemCommand(File directory, long timeoutMillis, int pollInterval, Map<String, String> env,
            String stdin, String stdout, String stderr) throws IOException {
        this(directory, timeoutMillis, pollInterval, env, checkIn(stdin), checkOut(stdout), checkOut(stderr));
    }

    private static InputStream checkIn(String stdin) throws FileNotFoundException {
        String in = JOrphanUtils.nullifyIfEmptyTrimmed(stdin);
        if (in == null) {
            return null;
        } else {
            return new FileInputStream(in);
        }
    }

    private static OutputStream checkOut(String path) throws IOException {
        String in = JOrphanUtils.nullifyIfEmptyTrimmed(path);
        if (in == null) {
            return null;
        } else {
            return new FileOutputStream(path);
        }
    }

    /**
     *
     * @param env Environment variables appended to environment (may be null)
     * @param directory File working directory (may be null)
     * @param timeoutMillis timeout in Milliseconds
     * @param pollInterval Value used to poll for Process execution end
     * @param stdin File name that will contain data to be input to process (may be null)
     * @param stdout File name that will contain out stream (may be null)
     * @param stderr File name that will contain err stream (may be null)
     */
    public SystemCommand(File directory, long timeoutMillis, int pollInterval, Map<String, String> env,
            InputStream stdin, OutputStream stdout, OutputStream stderr) {
        super();
        this.timeoutMillis = timeoutMillis;
        this.directory = directory;
        this.env = env;
        this.pollInterval = pollInterval;
        this.stdin = stdin;
        this.stdoutWasNull = stdout == null;
        if (stdout == null) {
            this.stdout = new ByteArrayOutputStream(); // capture the output
        } else {
            this.stdout = stdout;
        }
        this.stderr = stderr;
    }

    /**
     * @param arguments List of strings, not null
     * @return return code
     * @throws InterruptedException when execution was interrupted
     * @throws IOException when I/O error occurs while execution
     */
    public int run(List<String> arguments) throws InterruptedException, IOException, TimeoutException {
        return run(arguments, stdin, stdout, stderr);
    }

    // helper method to allow input and output to be changed for chaining
    private int run(List<String> arguments, InputStream in,
            OutputStream out,OutputStream err) throws InterruptedException, IOException, TimeoutException {
        Process proc = null;
        final ProcessBuilder procBuild = new ProcessBuilder(arguments);
        if (env != null) {
            procBuild.environment().putAll(env);
        }
        this.executionEnvironment = Collections.unmodifiableMap(procBuild.environment());
        procBuild.directory(directory);
        if (err == null) {
            procBuild.redirectErrorStream(true);
        }
        try
        {
            proc = procBuild.start();

            final OutputStream procOut = proc.getOutputStream();
            final InputStream procErr = proc.getErrorStream();
            final InputStream procIn = proc.getInputStream();

            final StreamCopier swerr;
            if (err != null){
                swerr = new StreamCopier(procErr, err);
                swerr.start();
            } else {
                swerr = null;
            }

            final StreamCopier swout = new StreamCopier(procIn, out);
            swout.start();

            final StreamCopier swin;
            if (in != null) {
                swin = new StreamCopier(in, procOut);
                swin.start();
            } else {
                swin = null;
                procOut.close(); // ensure the application does not hang if it requests input
            }
            int exitVal = waitForEndWithTimeout(proc, timeoutMillis);

            swout.join();
            if (swerr != null) {
                swerr.join();
            }
            if (swin != null) {
                swin.interrupt(); // the copying thread won't generally detect EOF
                swin.join();
            }
            procErr.close();
            procIn.close();
            procOut.close();
            return exitVal;
        } finally {
            if(proc != null) {
                try {
                    proc.destroy();
                } catch (Exception ignored) {
                    // Ignored
                }
            }
        }
    }

    /**
     * Pipe the output of one command into another
     *
     * @param arguments1 first command to run
     * @param arguments2 second command to run
     * @return exit status
     * @throws InterruptedException when execution gets interrupted
     * @throws TimeoutException when timeout is reached while execution
     * @throws IOException when I/O error occurs while execution
     */
    public int run(List<String> arguments1, List<String> arguments2) throws InterruptedException, IOException, TimeoutException {
        ByteArrayOutputStream out = new ByteArrayOutputStream(); // capture the intermediate output
        int exitCode = run(arguments1, stdin, out, stderr);
        if (exitCode == 0) {
            exitCode = run(arguments2, new ByteArrayInputStream(out.toByteArray()), stdout, stderr);
        }
        return exitCode;
    }

    /**
     * Wait for end of proc execution or timeout if timeoutInMillis is greater than 0
     * @param proc Process
     * @param timeoutInMillis long timeout in ms
     * @return proc exit value
     * @throws TimeoutException when timeout is reached while execution
     */
    private int waitForEndWithTimeout(Process proc, long timeoutInMillis) throws InterruptedException, TimeoutException {
        if (timeoutInMillis <= 0L) {
            return proc.waitFor();
        }
        if (proc.waitFor(timeoutInMillis, TimeUnit.MILLISECONDS)) {
            return proc.exitValue();
        }
        throw new TimeoutException("Process timeout reached after " + timeoutInMillis + " milliseconds");
    }

    /**
     * @return Out/Err stream contents
     */
    public String getOutResult() {
        if (stdoutWasNull) { // we are capturing output
            return stdout.toString(); // Default charset is probably appropriate here.
        } else {
            return "";
        }
    }

    /**
     * @return the executionEnvironment
     */
    public Map<String, String> getExecutionEnvironment() {
        return executionEnvironment;
    }
}
