/*
 * $Id$
 *
 * Copyright 1996-2008 Sun Microsystems, Inc.  All Rights Reserved.
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
 *
 * This code is free software; you can redistribute it and/or modify it
 * under the terms of the GNU General Public License version 2 only, as
 * published by the Free Software Foundation.  Sun designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Sun in the LICENSE file that accompanied this code.
 *
 * This code is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
 * version 2 for more details (a copy is included in the LICENSE file that
 * accompanied this code).
 *
 * You should have received a copy of the GNU General Public License version
 * 2 along with this work; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * Please contact Sun Microsystems, Inc., 4150 Network Circle, Santa Clara,
 * CA 95054 USA or visit www.sun.com if you need additional information or
 * have any questions.
 */
package com.sun.javatest.lib;

import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.Reader;
import java.util.Hashtable;


import com.sun.javatest.Command;
import com.sun.javatest.Status;
import com.sun.javatest.util.StringArray;

/**
 * A Command to execute an arbitrary OS command.
 **/
public class ProcessCommand extends Command
{
    /**
     * A stand-alone entry point for this command. An instance of this
     * command is created, and its <code>run</code> method invoked,
     * passing in the command line args and <code>System.out</code> and
     * <code>System.err</code> as the two streams.
     * @param args command line arguments for this command.
     * @see #run
     */
    public static void main(String[] args) {
        PrintWriter log = new PrintWriter(new OutputStreamWriter(System.err));
        PrintWriter ref = new PrintWriter(new OutputStreamWriter(System.out));
        Status s;
        try {
            Command cmd = new ProcessCommand();
            s = cmd.run(args, log, ref);
        }
        finally {
            log.flush();
            ref.flush();
        }
        s.exit();
    }

    /**
     * Set a status to be returned for a specific exit code, overwriting any
     * previous setting for this exit code. If the default status has not yet
     * been initialized, it is set to Status.error("unrecognized exit code").
     *
     * @param exitCode The process exit code for which to assign a status.
     * @param status The status to associate with the exit code.
     */
    public void setStatusForExit(int exitCode, Status status) {
        if (statusTable == null) {
            statusTable = new Hashtable();
            if (defaultStatus == null)
                defaultStatus = Status.error("unrecognized exit code");
        }
        statusTable.put(new Integer(exitCode), status);
    }

    /**
     * Set the default status to be returned for all exit codes.
     * This will not affect any values for specific exit codes that
     * may have been set with setStatusForExit. If this method is
     * not called, the default value will be Status.failed (for
     * backwards compatability) unless setStatusForExit has been
     * called, which sets the default value to Status.error.
     *
     * @param status The default status to use when a specific status
     * has not been set for a particular process exit code.
     */
    public void setDefaultStatus(Status status) {
        if (statusTable == null)
            statusTable = new Hashtable();
        defaultStatus = status;
    }

    /**
     * Set the directory in which to execute the process.
     * Use null to indicate the default directory.
     * @param dir the directory in which to execute the process.
     * @see #getExecDir
     */
    public void setExecDir(File dir) {
        execDir = dir;
    }

    /**
     * Get the directory in which to execute the process,
     * or null if none set.
     * @return the directory in which to execute the process.
     * @see #setExecDir
     */
    public File getExecDir() {
        return execDir;
    }

    /**
     * Internal routine to assist argument decoding prior to calling
     * setStatusForExit or setDefaultStatus
     */
    private void setStatus(String exitSpec, Status status) {
        // for now, we just support "default" and <integer>
        // in principle we could support ranges and lists too
        if (exitSpec.equals("default"))
            setDefaultStatus(status);
        else
            setStatusForExit(Integer.parseInt(exitSpec), status);
    }

    /**
     * Run the given command.
     * @param args      An array of strings composed of
     *                  <em>command-options</em>,
     *                  <em>environment-variables</em>,
     *                  <em>command</em>,
     *                  <em>command-arguments</em>.
     *                  <br>
     *
     *                  The <em>command-options</em> are an optional
     *                  set of options, each beginning with `-', to be
     *                  used by this object.
     *                  The options are
     *                  <dt>
     *                  <dt>-v
     *                  <dd>    verbose mode
     *                  <dt>-pass|-fail|-error <i>exit-code</i> <i>string</i>
     *                  <dd>    set the status to be returned for the given
     *                          exit code to one of
     *                          Status.passed/Status.failed/Status.error.
     *                          <i>exit-code</i> can be either an integer or "default".
     *                          <i>string</i> the message string provided in the
     *                          status object.
     *                  <dt>-execDir <i>execDir</i>
     *                  <dd>    set the directory in which to execute the command.
     *                  </dl>
     *                  <br>
     *
     *                  The <em>environment-variables</em> are an
     *                  optional list of environment variable to be supplied
     *                  to the command. They should be in the form
     *                  <em>NAME</em><code>=</code><em>VALUE</em>.
     *                  <br>
     *
     *                  The <em>command</em> identifies the command to
     *                  be executed. This name will be platform specific.
     *                  <br>
     *
     *                  The <em>command-arguments</em> are an optional
     *                  list of strings to be passed to the command to be
     *                  executed.
     * @param log       A stream for logging output.
     * @param ref       A stream for reference output, if the test requires it.
     *                  If not, it can be used as an additional logging stream.
     *
     * @return          The result of the method is obtained by calling
     *                  <code>getStatus</code> after the command completes.
     *                  The default behaviour is to use the explicit or default
     *                  status given in the arguments, or via the API. If none
     *                  have been set up, then the following values are used:
     *                  <code>Status.passed("exit code 0")</code>
     *                  if the command exited with exit status 0, or
     *                  <code>Status.failed("exit code " + exitCode)</code>
     *                  otherwise.  <code>getStatus</code> may be overridden
     *                  to provide different behavior.
     *
     * @see #getStatus
     *
     **/
    public Status run(String[] args, PrintWriter log, PrintWriter ref) {
        // analyse options
        int i = 0;
        for (; i < args.length && args[i].startsWith("-"); i++) {
            if (args[i].equals("-v"))
                verbose = true;
            else if (args[i].equals("-execDir") && i+1 < args.length) {
                execDir = new File(args[++i]);
            }
            else if (args[i].equals("-pass") && i+2 < args.length) {
                setStatus(args[++i], Status.passed(args[++i]));
            }
            else if (args[i].equals("-fail") && i+2 < args.length) {
                setStatus(args[++i], Status.failed(args[++i]));
            }
            else if (args[i].equals("-error") && i+2 < args.length) {
                setStatus(args[++i], Status.error(args[++i]));
            }
            else if (args[i].equals("-end")) {
                // -end is supported for the improbable event someone wants an
                // env var or command beginning with -
                i++;  // because the for-loop won't get a chance to do it
                break;
            }
            else
                return Status.error("Unrecognized option: " + args[i]);
        }

        // get environment variables for command
        int cmdEnvStart = i;
        while (i < args.length && (args[i].indexOf('=') != -1))
            i++;
        String[] cmdEnv = new String[i - cmdEnvStart];
        System.arraycopy(args, cmdEnvStart, cmdEnv, 0, cmdEnv.length);

        // get command name
        if (i == args.length)
            return Status.error("no command specified for " + getClass().getName());

        String[] cmd = new String[args.length - i];
        System.arraycopy(args, i, cmd, 0, cmd.length);

        return exec(cmd, cmdEnv, log, ref);
    }

    /**
     * Exceute a command, bypassing the standard argument decoding of 'run'.
     * @param cmd       The command to be executed
     * @param cmdEnv    The environment to be passed to the command
     * @param log       A stream for logging output.
     * @param ref       A stream for reference output, if the test requires it.
     *                  If not, it can be used as an additional logging stream.
     * @return          The result of the method is obtained by calling
     *                  <code>getStatus</code> after the command completes.
     * @see #run
     * @see #getStatus
     */
    public Status exec(String[] cmd, String[] cmdEnv, PrintWriter log, PrintWriter ref) {
        Process p = null;
        Status s = null;
        try {
            // The following is a workaround for a JDK problem ... if the cmdEnv
            // is empty, JDK assumes this means to inherit the parent environment.
            // (There is a separate call which more reasonably means that.)
            // So, to prevent the parent process' environment being inherited
            // we set the command environment to a dummy entry which will hopefully
            // not cause any problems for either the Runtime machinery or the
            // child process.
            if (cmdEnv != null && cmdEnv.length == 0) {
                String[] envWithDummyEntry = {/*empty*/"="/*empty*/};
                cmdEnv=envWithDummyEntry;
            }

            if (verbose) {
                log.println("Command is: " + StringArray.join(cmd));
                if (cmdEnv == null) {
                    log.println("Command environment is inherited from parent process");
                }
                else if (cmdEnv.length == 0) {
                    log.println("Command environment is empty");
                }
                else {
                    log.println("Command environment is:");
                    for (int i = 0; i < cmdEnv.length; i++)
                        log.println(cmdEnv[i]);
                }
                if (execDir != null)
                    log.println("Execution directory is " + execDir);
            }

            Runtime r = Runtime.getRuntime();
            p = (execDir == null ? r.exec(cmd, cmdEnv) : r.exec(cmd, cmdEnv, execDir));

            Reader in = new InputStreamReader(p.getInputStream()); // output stream from process
            StreamCopier refConnector = new StreamCopier(in, ref);
            refConnector.start();
            Reader err = new InputStreamReader(p.getErrorStream());
            StreamCopier logConnector = new StreamCopier(err, log);
            logConnector.start();

            OutputStream out = p.getOutputStream();  // input stream to process
            if (out != null)
                out.close();

            // wait for the stream copiers to complete (which may be interrupted by the
            // timeout thread
            refConnector.waitUntilDone();
            logConnector.waitUntilDone();

            // wait for the process to complete;
            // WARNING: in JDK1.0.2 this does not appear to be interruptible, which is
            // why we waited for the stream copiers to complete first ... because they are
            // interruptible.
            int exitCode = p.waitFor();
            //if (verbose > 0)
            //  log.report("command exited, exit=" + exitCode);

            in.close();
            err.close();

            return getStatus(exitCode, logConnector.exitStatus());
        }
        catch (InterruptedException e) {
            if (p != null)
                p.destroy();
            String msg = "Program `" + cmd[0] + "' interrupted! (timed out?)";
            s = (useFailedOnException ? Status.failed(msg) : Status.error(msg));
        }
        catch (IOException e) {
            String msg = "Error invoking program `" + cmd[0] + "': " + e;
            s = (useFailedOnException ? Status.failed(msg) : Status.error(msg));
        }
        return s;
    }

    /**
     * Generate a status for the command, based upon the command's exit code
     * and a status that may have been passed from the command by using
     * <code>status.exit()</code>.
     *
     * @param exitCode          The exit code from the command that was executed.
     * @param logStatus         If the command that was executed was a test program
     *                          and exited by calling <code>status.exit()</code>,
     *                          then logStatus will be set to `status'.  Otherwise,
     *                          it will be null.  The value of the status is passed
     *                          from the command by writing it as the last line to
     *                          stdout before exiting the process.   If it is not
     *                          received as the last line, the value will be lost.
     * @return          Unless overridden, the default is
     *                  <code>Status.passed("exit code 0")</code>
     *                  if the command exited with exit code 0, or
     *                  <code>Status.failed("exit code " + exitCode)</code>
     *                  otherwise.
     **/
    protected Status getStatus(int exitCode, Status logStatus) {
        if (logStatus != null)
            return logStatus;
        else if (statusTable != null) {
            Status s = (Status)(statusTable.get(new Integer(exitCode)));
            return (s == null ? defaultStatus.augment("exit code: " + exitCode) : s);
        }
        else if (exitCode == 0)
            return Status.passed("exit code 0");
        else
            return Status.failed("exit code " + exitCode);
    }

    private boolean verbose;


    /**
     * A thread to copy an input stream to an output stream
     */
    class StreamCopier extends Thread
    {
        /**
         * Create one.
         * @param from  the stream to copy from
         * @param out   the log to copy to
         */
        StreamCopier(Reader from, PrintWriter to) {
            super(Thread.currentThread().getName() + "_StreamCopier_" + (serial++));
            in = new BufferedReader(from);
            out = to;
            lastStatusLine = null;
        }

        /**
         * Set the thread going.
         */
        public void run() {
            //System.out.println("Copying stream");
            try {
                String line;
                while ((line = in.readLine()) != null) {
                    out.println(line);
                    // take care lastLine doesn't get set to null at EOF
                    // and ignore trailing newlines
                    if (line.startsWith(Status.EXIT_PREFIX))
                        lastStatusLine = line;
                }
            }
            catch (IOException e) {
            }
            //System.out.println("Stream copied");
            synchronized (this) {
                done = true;
                notifyAll();
            }
        }

        public synchronized boolean isDone() {
            return done;
        }

        /**
         * Blocks until the copy is complete, or until the thread is interrupted
         */
        public synchronized void waitUntilDone() throws InterruptedException {
            boolean interrupted = false;

            // poll interrupted flag, while waiting for copy to complete
            while (!(interrupted = Thread.interrupted()) && !done)
                wait(1000);

            //if (interrupted)
            //    System.out.println("TESTSCRIPT DETECTS interrupted() " + Thread.currentThread().getName());
            //else
            //    System.out.println("TESTSCRIPT waitUntilDone OK " + Thread.currentThread().getName());

            // workaround; if the exception hasn't been thrown already, do it now
            if (interrupted) {
                //System.out.println("Stream copier: throwing InterruptedException");
                throw new InterruptedException();
            }
        }

        /**
         * Return the status information from the child process if it returned
         * any on the log stream, otherwise return null.
         */
        public Status exitStatus() {
            if (lastStatusLine == null)
                return null;
            else
                return Status.parse(lastStatusLine.substring(Status.EXIT_PREFIX.length()));
        }


        private BufferedReader in;
        private PrintWriter out;
        private String lastStatusLine;
        private boolean done;

    }

    private static boolean useFailedOnException =
        Boolean.getBoolean("javatest.processCommand.useFailedOnException");

    private static int serial;
    private Hashtable statusTable;
    private Status defaultStatus;
    private File execDir;
}
