/*
 * Copyright (c) 2010, 2016, Oracle and/or its affiliates. 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.  Oracle designates this
 * particular file as subject to the "Classpath" exception as provided
 * by Oracle 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 Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
 * or visit www.oracle.com if you need additional information or have any
 * questions.
 */

package jdk.nashorn.internal.test.framework;

import static jdk.nashorn.internal.test.framework.TestConfig.TEST_FAILED_LIST_FILE;
import static jdk.nashorn.internal.test.framework.TestConfig.TEST_JS_ENABLE_STRICT_MODE;
import static jdk.nashorn.internal.test.framework.TestConfig.TEST_JS_EXCLUDES_FILE;
import static jdk.nashorn.internal.test.framework.TestConfig.TEST_JS_EXCLUDE_LIST;
import static jdk.nashorn.internal.test.framework.TestConfig.TEST_JS_FRAMEWORK;
import static jdk.nashorn.internal.test.framework.TestConfig.TEST_JS_ROOTS;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.io.PrintWriter;
import java.io.StringReader;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.Callable;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import jdk.nashorn.internal.test.framework.TestFinder.TestFactory;

/**
 * Parallel test runner runs tests in multiple threads - but avoids any dependency
 * on third-party test framework library such as TestNG.
 */
@SuppressWarnings("javadoc")
public class ParallelTestRunner {

    // ParallelTestRunner-specific
    private static final String    TEST_JS_THREADS     = "test.js.threads";
    private static final String    TEST_JS_REPORT_FILE = "test.js.report.file";
    // test262 does a lot of eval's and the JVM hates multithreaded class definition, so lower thread count is usually faster.
    private static final int       THREADS = Integer.getInteger(TEST_JS_THREADS, Runtime.getRuntime().availableProcessors() > 4 ? 4 : 2);

    private final List<ScriptRunnable> tests    = new ArrayList<>();
    private final Set<String>      orphans  = new TreeSet<>();
    private final ExecutorService  executor = Executors.newFixedThreadPool(THREADS);

    // Ctrl-C handling
    private final CountDownLatch   finishedLatch = new CountDownLatch(1);
    private final Thread           shutdownHook  = new Thread() {
                                                       @Override
                                                       public void run() {
                                                           if (!executor.isTerminated()) {
                                                               executor.shutdownNow();
                                                               try {
                                                                   executor.awaitTermination(25, TimeUnit.SECONDS);
                                                                   finishedLatch.await(5, TimeUnit.SECONDS);
                                                               } catch (final InterruptedException e) {
                                                                   // empty
                                                               }
                                                           }
                                                       }
                                                   };

    public ParallelTestRunner() throws Exception {
        suite();
    }

    private static PrintStream outputStream() {
        final String reportFile = System.getProperty(TEST_JS_REPORT_FILE, "");
        PrintStream output = System.out;

        if (!reportFile.isEmpty()) {
            try {
                output = new PrintStream(new OutputStreamDelegator(System.out, new FileOutputStream(reportFile)));
            } catch (final IOException e) {
                System.err.println(e);
            }
        }

        return output;
    }

    public static final class ScriptRunnable extends AbstractScriptRunnable implements Callable<ScriptRunnable.Result> {
        private final Result                result   = new Result();

        public class Result {
            private boolean  passed = true;
            public String    expected;
            public String    out;
            public String    err;
            public Throwable exception;

            public ScriptRunnable getTest() {
                return ScriptRunnable.this;
            }

            public boolean passed() {
                return passed;
            }

            @Override
            public String toString() {
                return getTest().toString();
            }
        }

        public ScriptRunnable(final String framework, final File testFile, final List<String> engineOptions, final Map<String, String> testOptions, final List<String> scriptArguments) {
            super(framework, testFile, engineOptions, testOptions, scriptArguments);
        }

        @Override
        protected void log(final String msg) {
            System.err.println(msg);
        }

        @Override
        protected void fail(final String message) {
            throw new TestFailedError(message);
        }

        @Override
        protected void compile() throws IOException {
            final ByteArrayOutputStream out = new ByteArrayOutputStream();
            final ByteArrayOutputStream err = new ByteArrayOutputStream();
            final List<String> args = getCompilerArgs();
            int errors;
            try {
                errors = evaluateScript(out, err, args.toArray(new String[0]));
            } catch (final AssertionError e) {
                final PrintWriter writer = new PrintWriter(err);
                e.printStackTrace(writer);
                writer.flush();
                errors = 1;
            }
            if (errors != 0 || checkCompilerMsg) {
                result.err = err.toString();
                if (expectCompileFailure || checkCompilerMsg) {
                    final PrintStream outputDest = new PrintStream(new FileOutputStream(getErrorFileName()));
                    TestHelper.dumpFile(outputDest, new StringReader(new String(err.toByteArray())));
                    outputDest.println("--");
                }
                if (errors != 0 && !expectCompileFailure) {
                    fail(String.format("%d errors compiling %s", errors, testFile));
                }
                if (checkCompilerMsg) {
                    compare(getErrorFileName(), expectedFileName, true);
                }
            }
            if (expectCompileFailure && errors == 0) {
                fail(String.format("No errors encountered compiling negative test %s", testFile));
            }
        }

        @Override
        protected void execute() {
            final List<String> args = getRuntimeArgs();
            final ByteArrayOutputStream out = new ByteArrayOutputStream();
            final ByteArrayOutputStream err = new ByteArrayOutputStream();

            try {
                final int errors = evaluateScript(out, err, args.toArray(new String[0]));

                if (errors != 0 || err.size() > 0) {
                    if (expectRunFailure) {
                        return;
                    }
                    if (!ignoreStdError) {

                        try (OutputStream outputFile = new FileOutputStream(getOutputFileName()); OutputStream errorFile = new FileOutputStream(getErrorFileName())) {
                            outputFile.write(out.toByteArray());
                            errorFile.write(err.toByteArray());
                        }

                        result.out = out.toString();
                        result.err = err.toString();
                        fail(err.toString());
                    }
                }

                if (compare) {
                    final File expectedFile = new File(expectedFileName);
                    try {
                        BufferedReader expected;
                        if (expectedFile.exists()) {
                            expected = new BufferedReader(new FileReader(expectedFile));
                        } else {
                            expected = new BufferedReader(new StringReader(""));
                        }
                        compare(new BufferedReader(new StringReader(out.toString())), expected, false);
                    } catch (final Throwable ex) {
                        if (expectedFile.exists()) {
                            copyExpectedFile();
                        }
                        try (OutputStream outputFile = new FileOutputStream(getOutputFileName()); OutputStream errorFile = new FileOutputStream(getErrorFileName())) {
                            outputFile.write(out.toByteArray());
                            errorFile.write(err.toByteArray());
                        }
                        ex.printStackTrace();
                        throw ex;
                    }
                }
            } catch (final IOException e) {
                if (!expectRunFailure) {
                    fail("Failure running test " + testFile + ": " + e.getMessage());
                } // else success
            }
        }

        private void compare(final String fileName, final String expected, final boolean compareCompilerMsg) throws IOException {
            final File expectedFile = new File(expected);

            BufferedReader expectedReader;
            if (expectedFile.exists()) {
                expectedReader = new BufferedReader(new InputStreamReader(new FileInputStream(expectedFileName)));
            } else {
                expectedReader = new BufferedReader(new StringReader(""));
            }

            final BufferedReader actual = new BufferedReader(new InputStreamReader(new FileInputStream(fileName)));

            compare(actual, expectedReader, compareCompilerMsg);
        }

        private void copyExpectedFile() {
            if (!new File(expectedFileName).exists()) {
                return;
            }
            // copy expected file overwriting existing file and preserving last
            // modified time of source
            try {
                Files.copy(FileSystems.getDefault().getPath(expectedFileName), FileSystems.getDefault().getPath(getCopyExpectedFileName()), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
            } catch (final IOException ex) {
                fail("failed to copy expected " + expectedFileName + " to " + getCopyExpectedFileName() + ": " + ex.getMessage());
            }
        }

        @Override
        public Result call() {
            try {
                runTest();
            } catch (final Throwable ex) {
                result.exception = ex;
                result.passed = false;
                ex.printStackTrace();
            }
            return result;
        }

        private String getOutputFileName() {
            buildDir.mkdirs();
            return outputFileName;
        }

        private String getErrorFileName() {
            buildDir.mkdirs();
            return errorFileName;
        }

        private String getCopyExpectedFileName() {
            buildDir.mkdirs();
            return copyExpectedFileName;
        }
    }

    private void suite() throws Exception {
        Locale.setDefault(new Locale(""));
        System.setOut(outputStream());

        final TestFactory<ScriptRunnable> testFactory = new TestFactory<ScriptRunnable>() {
            @Override
            public ScriptRunnable createTest(final String framework, final File testFile, final List<String> engineOptions, final Map<String, String> testOptions, final List<String> arguments) {
                return new ScriptRunnable(framework, testFile, engineOptions, testOptions, arguments);
            }

            @Override
            public void log(final String msg) {
                System.err.println(msg);
            }
        };

        TestFinder.findAllTests(tests, orphans, testFactory);

        Collections.sort(tests, new Comparator<ScriptRunnable>() {
            @Override
            public int compare(final ScriptRunnable o1, final ScriptRunnable o2) {
                return o1.testFile.compareTo(o2.testFile);
            }
        });
    }

    @SuppressWarnings("resource")
    public boolean run() throws IOException {
        final int testCount = tests.size();
        int passCount = 0;
        int doneCount = 0;
        System.out.printf("Found %d tests.\n", testCount);
        final long startTime = System.nanoTime();

        Runtime.getRuntime().addShutdownHook(shutdownHook);

        final List<Future<ScriptRunnable.Result>> futures = new ArrayList<>();
        for (final ScriptRunnable test : tests) {
            futures.add(executor.submit(test));
        }

        executor.shutdown();
        try {
            executor.awaitTermination(60, TimeUnit.MINUTES);
        } catch (final InterruptedException ex) {
            // empty
        }

        final List<ScriptRunnable.Result> results = new ArrayList<>();
        for (final Future<ScriptRunnable.Result> future : futures) {
            if (future.isDone()) {
                try {
                    final ScriptRunnable.Result result = future.get();
                    results.add(result);
                    doneCount++;
                    if (result.passed()) {
                        passCount++;
                    }
                } catch (CancellationException | ExecutionException ex) {
                    ex.printStackTrace();
                } catch (final InterruptedException ex) {
                    assert false : "should not reach here";
                }
            }
        }

        Collections.sort(results, new Comparator<ScriptRunnable.Result>() {
            @Override
            public int compare(final ScriptRunnable.Result o1, final ScriptRunnable.Result o2) {
                return o1.getTest().testFile.compareTo(o2.getTest().testFile);
            }
        });

        boolean hasFailed = false;
        final String failedList = System.getProperty(TEST_FAILED_LIST_FILE);
        final boolean hasFailedList = failedList != null;
        final boolean hadPreviouslyFailingTests = hasFailedList && new File(failedList).length() > 0;
        final FileWriter failedFileWriter = hasFailedList ? new FileWriter(failedList) : null;
        try {
            final PrintWriter failedListWriter = failedFileWriter == null ? null : new PrintWriter(failedFileWriter);
            for (final ScriptRunnable.Result result : results) {
                if (!result.passed()) {
                    if (hasFailed == false) {
                        hasFailed = true;
                        System.out.println();
                        System.out.println("FAILED TESTS");
                    }

                    System.out.println(result.getTest());
                    if(failedFileWriter != null) {
                        failedListWriter.println(result.getTest().testFile.getPath());
                    }
                    if (result.exception != null) {
                        final String exceptionString = result.exception instanceof TestFailedError ? result.exception.getMessage() : result.exception.toString();
                        System.out.print(exceptionString.endsWith("\n") ? exceptionString : exceptionString + "\n");
                        System.out.print(result.out != null ? result.out : "");
                    }
                }
            }
        } finally {
            if(failedFileWriter != null) {
                failedFileWriter.close();
            }
        }
        final double timeElapsed = (System.nanoTime() - startTime) / 1e9; // [s]
        System.out.printf("Tests run: %d/%d tests, passed: %d (%.2f%%), failed: %d. Time elapsed: %.0fmin %.0fs.\n", doneCount, testCount, passCount, 100d * passCount / doneCount, doneCount - passCount, timeElapsed / 60, timeElapsed % 60);
        System.out.flush();

        finishedLatch.countDown();

        if (hasFailed) {
            throw new AssertionError("TEST FAILED");
        }

        if(hasFailedList) {
            new File(failedList).delete();
        }

        if(hadPreviouslyFailingTests) {
            System.out.println();
            System.out.println("Good job on getting all your previously failing tests pass!");
            System.out.println("NOW re-running all tests to make sure you haven't caused any NEW test failures.");
            System.out.println();
        }

        return hadPreviouslyFailingTests;
    }

    public static void main(final String[] args) throws Exception {
        parseArgs(args);

        while (new ParallelTestRunner().run()) {
            //empty
        }
    }

    private static void parseArgs(final String[] args) {
        if (args.length > 0) {
            String roots = "";
            String reportFile = "";
            for (int i = 0; i < args.length; i++) {
                if (args[i].equals("--roots") && i != args.length - 1) {
                    roots += args[++i] + " ";
                } else if (args[i].equals("--report-file") && i != args.length - 1) {
                    reportFile = args[++i];
                } else if (args[i].equals("--test262")) {
                    try {
                        setTest262Properties();
                    } catch (final IOException ex) {
                        System.err.println(ex);
                    }
                }
            }
            if (!roots.isEmpty()) {
                System.setProperty(TEST_JS_ROOTS, roots.trim());
            }
            if (!reportFile.isEmpty()) {
                System.setProperty(TEST_JS_REPORT_FILE, reportFile);
            }
        }
    }

    private static void setTest262Properties() throws IOException {
        System.setProperty(TEST_JS_ROOTS, "test/nashorn/script/external/test262/test/suite/");
        System.setProperty(TEST_JS_FRAMEWORK, "test/nashorn/script/test262.js test/nashorn/script/external/test262/test/harness/framework.js test/nashorn/script/external/test262/test/harness/sta.js");
        System.setProperty(TEST_JS_EXCLUDES_FILE, "test/nashorn/script/external/test262/test/config/excludelist.xml");
        System.setProperty(TEST_JS_ENABLE_STRICT_MODE, "true");

        final Properties projectProperties = new Properties();
        projectProperties.load(new FileInputStream("project.properties"));
        String excludeList = projectProperties.getProperty("test262-test-sys-prop.test.js.exclude.list", "");
        final Pattern pattern = Pattern.compile("\\$\\{([^}]+)}");
        for (;;) {
            final Matcher matcher = pattern.matcher(excludeList);
            if (!matcher.find()) {
                break;
            }
            final String propertyValue = projectProperties.getProperty(matcher.group(1), "");
            excludeList = excludeList.substring(0, matcher.start()) + propertyValue + excludeList.substring(matcher.end());
        }
        System.setProperty(TEST_JS_EXCLUDE_LIST, excludeList);
    }

    public static final class OutputStreamDelegator extends OutputStream {
        private final OutputStream[] streams;

        public OutputStreamDelegator(final OutputStream... streams) {
            this.streams = streams;
        }

        @Override
        public void write(final int b) throws IOException {
            for (final OutputStream stream : streams) {
                stream.write(b);
            }
        }

        @Override
        public void flush() throws IOException {
            for (final OutputStream stream : streams) {
                stream.flush();
            }
        }
    }
}

final class TestFailedError extends Error {
    private static final long serialVersionUID = 1L;

    public TestFailedError(final String message) {
        super(message);
    }

    public TestFailedError(final String message, final Throwable cause) {
        super(message, cause);
    }

    public TestFailedError(final Throwable cause) {
        super(cause);
    }
}
