/*
 *  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.tools.ant.taskdefs.optional.junit;

import java.util.Hashtable;
import java.util.Properties;
import java.util.Vector;

import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.PropertyHelper;

/**
 * Run a single JUnit test.
 *
 * <p>The JUnit test is actually run by {@link JUnitTestRunner}.
 * So read the doc comments for that class :)</p>
 *
 * @since Ant 1.2
 *
 * @see JUnitTask
 * @see JUnitTestRunner
 */
public class JUnitTest extends BaseTest implements Cloneable {

    /** the name of the test case */
    private String name = null;

    /**
     * whether the list of test methods has been specified
     * @see #setMethods(java.lang.String)
     * @see #setMethods(java.lang.String[])
     */
    private boolean methodsSpecified = false;

    /** comma-separated list of names of test methods to execute */
    private String methodsList = null;

    /** the names of test methods to execute */
    private String[] methods = null;

    /** the name of the result file */
    private String outfile = null;

    // @todo this is duplicating TestResult information. Only the time is not
    // part of the result. So we'd better derive a new class from TestResult
    // and deal with it. (SB)
    private long runs, failures, errors;
    /**
    @since Ant 1.9.0
    */
    private long skips;

    private long runTime;

    private int antThreadID;

    // Snapshot of the system properties
    private Properties props = null;

    /** No arg constructor. */
    public JUnitTest() {
    }

    /**
     * Constructor with name.
     * @param name the name of the test.
     */
    public JUnitTest(String name) {
        this.name  = name;
    }

    /**
     * Constructor with options.
     * @param name the name of the test.
     * @param haltOnError if true halt the tests if there is an error.
     * @param haltOnFailure if true halt the tests if there is a failure.
     * @param filtertrace if true filter stack traces.
     */
    public JUnitTest(String name, boolean haltOnError, boolean haltOnFailure,
            boolean filtertrace) {
        this(name, haltOnError, haltOnFailure, filtertrace, null, 0);
    }

    /**
     * Constructor with options.
     * @param name the name of the test.
     * @param haltOnError if true halt the tests if there is an error.
     * @param haltOnFailure if true halt the tests if there is a failure.
     * @param filtertrace if true filter stack traces.
     * @param methods if non-null run only these test methods
     * @since 1.8.2
     */
    public JUnitTest(String name, boolean haltOnError, boolean haltOnFailure,
                     boolean filtertrace, String[] methods) {
        this(name, haltOnError, haltOnFailure, filtertrace, methods, 0);
    }

    /**
     * Constructor with options.
     * @param name the name of the test.
     * @param haltOnError if true halt the tests if there is an error.
     * @param haltOnFailure if true halt the tests if there is a failure.
     * @param filtertrace if true filter stack traces.
     * @param methods if non-null run only these test methods
     * @param thread Ant thread ID in which test is currently running
     * @since 1.9.4
     */
    public JUnitTest(String name, boolean haltOnError, boolean haltOnFailure,
                     boolean filtertrace, String[] methods, int thread) {
        this.name  = name;
        this.haltOnError = haltOnError;
        this.haltOnFail = haltOnFailure;
        this.filtertrace = filtertrace;
        this.methodsSpecified = methods != null;
        this.methods = methodsSpecified ? (String[]) methods.clone() : null;
        this.antThreadID = thread;
    }

    /**
     * Sets names of individual test methods to be executed.
     * @param value comma-separated list of names of individual test methods
     *              to be executed,
     *              or <code>null</code> if all test methods should be executed
     * @since 1.8.2
     */
    public void setMethods(String value) {
        methodsList = value;
        methodsSpecified = (value != null);
        methods = null;
    }

    /**
     * Sets names of individual test methods to be executed.
     * @param value non-empty array of names of test methods to be executed
     * @see #setMethods(String)
     * @since 1.8.2
     */
    void setMethods(String[] value) {
        methods = value;
        methodsSpecified = (value != null);
        methodsList = null;
    }

    /**
     * Set the name of the test class.
     * @param value the name to use.
     */
    public void setName(String value) {
        name = value;
    }

    /**
     * Set the thread id
     * @param thread the Ant id of the thread running this test
     * (this is not the system process or thread id)
     * (this will be 0 in single-threaded mode).
     * @since Ant 1.9.4
     */
    public void setThread(int thread) {
        this.antThreadID = thread;
    }

    /**
     * Set the name of the output file.
     * @param value the name of the output file to use.
     */
    public void setOutfile(String value) {
        outfile = value;
    }

    /**
     * Informs whether a list of test methods has been specified in this test.
     * @return <code>true</code> if test methods to be executed have been
     *         specified, <code>false</code> otherwise
     * @see #setMethods(java.lang.String)
     * @see #setMethods(java.lang.String[])
     * @since 1.8.2
     */
    boolean hasMethodsSpecified() {
        return methodsSpecified;
    }

    /**
     * Get names of individual test methods to be executed.
     *
     * @return array of names of the individual test methods to be executed,
     *         or <code>null</code> if all test methods in the suite
     *         defined by the test class will be executed
     * @since 1.8.2
     */
    String[] getMethods() {
        if (methodsSpecified && (methods == null)) {
            resolveMethods();
        }
        return methods;
    }

    /**
     * Gets a comma-separated list of names of methods that are to be executed
     * by this test.
     * @return the comma-separated list of test method names, or an empty
     *         string of no method is to be executed, or <code>null</code>
     *         if no method is specified
     * @since 1.8.2
     */
    String getMethodsString() {
        if ((methodsList == null) && methodsSpecified) {
            if (methods.length == 0) {
                methodsList = "";
            } else if (methods.length == 1) {
                methodsList = methods[0];
            } else {
                StringBuilder buf = new StringBuilder(methods.length * 16);
                buf.append(methods[0]);
                for (int i = 1; i < methods.length; i++) {
                    buf.append(',').append(methods[i]);
                }
                methodsList = buf.toString();
            }
        }
        return methodsList;
    }

    /**
     * Computes the value of the {@link #methods} field from the value
     * of the {@link #methodsList} field, if it has not been computed yet.
     * @exception BuildException if the value of the {@link #methodsList} field
     *                           was invalid
     * @since 1.8.2
     */
    void resolveMethods() {
        if ((methods == null) && methodsSpecified) {
            try {
                methods = parseTestMethodNamesList(methodsList);
            } catch (IllegalArgumentException ex) {
                throw new BuildException(
                        "Invalid specification of test methods: \""
                            + methodsList
                            + "\"; expected: comma-separated list of valid Java identifiers",
                        ex);
            }
        }
    }

    /**
     * Parses a comma-separated list of method names and check their validity.
     * @param methodNames comma-separated list of method names to be parsed
     * @return array of individual test method names
     * @exception  java.lang.IllegalArgumentException
     *             if the given string is <code>null</code> or if it is not
     *             a comma-separated list of valid Java identifiers;
     *             an empty string is acceptable and is handled as an empty
     *             list
     * @since 1.8.2
     */
    public static String[] parseTestMethodNamesList(String methodNames)
                                            throws IllegalArgumentException {
        if (methodNames == null) {
            throw new IllegalArgumentException("methodNames is <null>");
        }

        methodNames = methodNames.trim();

        int length = methodNames.length();
        if (length == 0) {
            return new String[0];
        }

        /* strip the trailing comma, if any */
        if (methodNames.charAt(length - 1) == ',') {
            methodNames = methodNames.substring(0, length - 1).trim();
            length = methodNames.length();
            if (length == 0) {
                throw new IllegalArgumentException("Empty method name");
            }
        }

        final char[] chars = methodNames.toCharArray();
        /* easy detection of one particular case of illegal string: */
        if (chars[0] == ',') {
            throw new IllegalArgumentException("Empty method name");
        }
        /* count number of method names: */
        int wordCount = 1;
        for (int i = 1; i < chars.length; i++) {
            if (chars[i] == ',') {
                wordCount++;
            }
        }
        /* prepare the resulting array: */
        String[] result = new String[wordCount];
        /* parse the string: */
        final int stateBeforeWord = 1;
        final int stateInsideWord = 2;
        final int stateAfterWord = 3;
        //
        int state = stateBeforeWord;
        int wordStartIndex = -1;
        int wordIndex = 0;
        for (int i = 0; i < chars.length; i++) {
            char c = chars[i];
            switch (state) {
                case stateBeforeWord:
                    if (c == ',') {
                        throw new IllegalArgumentException("Empty method name");
                    } else if (c == ' ') {
                        // remain in the same state
                    } else if (Character.isJavaIdentifierStart(c)) {
                        wordStartIndex = i;
                        state = stateInsideWord;
                    } else {
                        throw new IllegalArgumentException("Illegal start of method name: " + c);
                    }
                    break;
                case stateInsideWord:
                    if (c == ',') {
                        result[wordIndex++] = methodNames.substring(wordStartIndex, i);
                        state = stateBeforeWord;
                    } else if (c == ' ') {
                        result[wordIndex++] = methodNames.substring(wordStartIndex, i);
                        state = stateAfterWord;
                    } else if (Character.isJavaIdentifierPart(c)) {
                        // remain in the same state
                    } else {
                        throw new IllegalArgumentException("Illegal character in method name: " + c);
                    }
                    break;
                case stateAfterWord:
                    if (c == ',') {
                        state = stateBeforeWord;
                    } else if (c == ' ') {
                        // remain in the same state
                    } else {
                        throw new IllegalArgumentException("Space in method name");
                    }
                    break;
                default:
                    // this should never happen
            }
        }
        switch (state) {
            case stateBeforeWord:
            case stateAfterWord:
                break;
            case stateInsideWord:
                result[wordIndex++] = methodNames.substring(wordStartIndex, chars.length);
                break;
            default:
                // this should never happen
        }
        return result;
    }

    /**
     * Get the name of the test class.
     * @return the name of the test.
     */
    public String getName() {
        return name;
    }

    /**
     * Get the Ant id of the thread running the test.
     * @return the thread id
     */
    public int getThread() {
        return antThreadID;
    }

    /**
     * Get the name of the output file
     *
     * @return the name of the output file.
     */
    public String getOutfile() {
        return outfile;
    }

    /**
     * Set the number of runs, failures, errors, and skipped tests.
     * @param runs     the number of runs.
     * @param failures the number of failures.
     * @param errors   the number of errors.
     * Kept for backward compatibility with Ant 1.8.4
     */
    public void setCounts(long runs, long failures, long errors) {
        this.runs = runs;
        this.failures = failures;
        this.errors = errors;
    }
    /**
     * Set the number of runs, failures, errors, and skipped tests.
     * @param runs     the number of runs.
     * @param failures the number of failures.
     * @param errors   the number of errors.
     * @param skips   the number of skipped tests.
     * @since Ant 1.9.0
     */
    public void setCounts(long runs, long failures, long errors, long skips) {
        this.runs = runs;
        this.failures = failures;
        this.errors = errors;
        this.skips = skips;
    }

    /**
     * Set the runtime.
     * @param runTime the time in milliseconds.
     */
    public void setRunTime(long runTime) {
        this.runTime = runTime;
    }

    /**
     * Get the number of runs.
     * @return the number of runs.
     */
    public long runCount() {
        return runs;
    }

    /**
     * Get the number of failures.
     * @return the number of failures.
     */
    public long failureCount() {
        return failures;
    }

    /**
     * Get the number of errors.
     * @return the number of errors.
     */
    public long errorCount() {
        return errors;
    }

    /**
     * Get the number of skipped tests.
     * @return the number of skipped tests.
     */
    public long skipCount() {
        return skips;
    }

    /**
     * Get the run time.
     * @return the run time in milliseconds.
     */
    public long getRunTime() {
        return runTime;
    }

    /**
     * Get the properties used in the test.
     * @return the properties.
     */
    public Properties getProperties() {
        return props;
    }

    /**
     * Set the properties to be used in the test.
     * @param p the properties.
     *          This is a copy of the projects ant properties.
     */
    public void setProperties(Hashtable<?, ?> p) {
        props = new Properties();
        p.forEach(props::put);
    }

    /**
     * Check if this test should run based on the if and unless
     * attributes.
     * @param p the project to use to check if the if and unless
     *          properties exist in.
     * @return true if this test or testsuite should be run.
     */
    public boolean shouldRun(Project p) {
        PropertyHelper ph = PropertyHelper.getPropertyHelper(p);
        return ph.testIfCondition(getIfCondition())
            && ph.testUnlessCondition(getUnlessCondition());
    }

    /**
     * Get the formatters set for this test.
     * @return the formatters as an array.
     */
    public FormatterElement[] getFormatters() {
        FormatterElement[] fes = new FormatterElement[formatters.size()];
        formatters.copyInto(fes);
        return fes;
    }

    /**
     * Convenient method to add formatters to a vector
     */
    void addFormattersTo(Vector<? super FormatterElement> v) {
        final int count = formatters.size();
        for (int i = 0; i < count; i++) {
            v.addElement(formatters.elementAt(i));
        }
    }

    /**
     * @since Ant 1.5
     * @return a clone of this test.
     */
    @SuppressWarnings("unchecked")
    @Override
    public Object clone() {
        try {
            JUnitTest t = (JUnitTest) super.clone();
            t.props = props == null ? null : (Properties) props.clone();
            t.formatters = (Vector<FormatterElement>) formatters.clone();
            return t;
        } catch (CloneNotSupportedException e) {
            // plain impossible
            return this;
        }
    }
}
