/*
 *  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
 *
 *      https://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.junitlauncher.confined;

import org.apache.tools.ant.AntClassLoader;
import org.apache.tools.ant.BuildException;
import org.apache.tools.ant.Project;
import org.apache.tools.ant.Task;
import org.apache.tools.ant.taskdefs.Execute;
import org.apache.tools.ant.taskdefs.ExecuteWatchdog;
import org.apache.tools.ant.taskdefs.LogOutputStream;
import org.apache.tools.ant.taskdefs.PumpStreamHandler;
import org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.TestDefinition.ForkedRepresentation;
import org.apache.tools.ant.types.CommandlineJava;
import org.apache.tools.ant.types.Environment;
import org.apache.tools.ant.types.Path;
import org.apache.tools.ant.util.FileUtils;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Hashtable;
import java.util.List;
import java.util.Optional;
import java.util.Properties;
import java.util.StringTokenizer;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;

import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.NANOSECONDS;
import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_EXCLUDE_TAGS;
import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_HALT_ON_FAILURE;
import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_INCLUDE_TAGS;
import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_PRINT_SUMMARY;
import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ELM_LAUNCH_DEF;


/**
 * An Ant {@link Task} responsible for launching the JUnit platform for running tests.
 * This requires a minimum of JUnit 5, since that's the version in which the JUnit platform launcher
 * APIs were introduced.
 * <p>
 * This task in itself doesn't run the JUnit tests, instead the sole responsibility of
 * this task is to setup the JUnit platform launcher, build requests, launch those requests and then parse the
 * result of the execution to present in a way that's been configured on this Ant task.
 * </p>
 * <p>
 * Furthermore, this task allows users control over which classes to select for passing on to the JUnit 5
 * platform for test execution. It however, is solely the JUnit 5 platform, backed by test engines that
 * decide and execute the tests.
 *
 * @see <a href="https://junit.org/junit5/">JUnit 5 documentation</a>
 */
public class JUnitLauncherTask extends Task {

    private static final String LAUNCHER_SUPPORT_CLASS_NAME = "org.apache.tools.ant.taskdefs.optional.junitlauncher.LauncherSupport";
    private static final String IN_VM_TEST_EXECUTION_CONTEXT_CLASS_NAME = "org.apache.tools.ant.taskdefs.optional.junitlauncher.InVMExecution";
    private static final String TEST_EXECUTION_CONTEXT_CLASS_NAME = "org.apache.tools.ant.taskdefs.optional.junitlauncher.TestExecutionContext";

    private Path classPath;
    private boolean haltOnFailure;
    private String failureProperty;
    private boolean printSummary;
    private final List<TestDefinition> tests = new ArrayList<>();
    private final List<ListenerDefinition> listeners = new ArrayList<>();
    private final List<String> includeTags = new ArrayList<>();
    private final List<String> excludeTags = new ArrayList<>();

    public JUnitLauncherTask() {
    }

    @Override
    public void execute() throws BuildException {
        if (this.tests.isEmpty()) {
            return;
        }
        final Project project = getProject();
        for (final TestDefinition test : this.tests) {
            if (!test.shouldRun(project)) {
                log("Excluding test " + test + " since it's considered not to run " +
                        "in context of project " + project, Project.MSG_DEBUG);
                continue;
            }
            final ForkDefinition forkDefinition = test.getForkDefinition();
            if (forkDefinition == null) {
                launchViaReflection(new InVMLaunch(Collections.singletonList(test)));
            } else {
                final List<ForkedRepresentation> forkedReps = test.toForkedRepresentations();
                final long timeout = forkDefinition.getTimeout(); // in millis
                // compute the "end time" (if any)
                final Optional<Long> deadlineNanos = timeout == -1
                        ? Optional.empty() // no timeout
                        : Optional.of(System.nanoTime() + MILLISECONDS.toNanos(timeout));
                for (final ForkedRepresentation forkedRep : forkedReps) {
                    forkTest(test, forkDefinition, forkedRep, deadlineNanos);
                }
            }
        }
    }

    /**
     * Adds the {@link Path} to the classpath which will be used for execution of the tests
     *
     * @param path The classpath
     */
    public void addConfiguredClassPath(final Path path) {
        if (this.classPath == null) {
            // create a "wrapper" path which can hold on to multiple
            // paths that get passed to this method (if at all the task in the build is
            // configured with multiple classpaht elements)
            this.classPath = new Path(getProject());
        }
        this.classPath.add(path);
    }

    /**
     * Adds a {@link SingleTestClass} that will be passed on to the underlying JUnit platform
     * for possible execution of the test
     *
     * @param test The test
     */
    public void addConfiguredTest(final SingleTestClass test) {
        this.preConfigure(test);
        this.tests.add(test);
    }

    /**
     * Adds {@link TestClasses} that will be passed on to the underlying JUnit platform for
     * possible execution of the tests
     *
     * @param testClasses The test classes
     */
    public void addConfiguredTestClasses(final TestClasses testClasses) {
        this.preConfigure(testClasses);
        this.tests.add(testClasses);
    }

    /**
     * Adds a {@link ListenerDefinition listener} which will be enrolled for listening to test
     * execution events
     *
     * @param listener The listener
     */
    public void addConfiguredListener(final ListenerDefinition listener) {
        this.listeners.add(listener);
    }

    public void setHaltonfailure(final boolean haltonfailure) {
        this.haltOnFailure = haltonfailure;
    }

    public void setFailureProperty(final String failureProperty) {
        this.failureProperty = failureProperty;
    }

    public void setPrintSummary(final boolean printSummary) {
        this.printSummary = printSummary;
    }

    /**
     * Tags to include. Will trim each tag.
     *
     * @param includes comma separated list of tags to include while running the tests.
     * @since Ant 1.10.7
     */
    public void setIncludeTags(final String includes) {
        final StringTokenizer tokens = new StringTokenizer(includes, ",");
        while (tokens.hasMoreTokens()) {
            includeTags.add(tokens.nextToken().trim());
        }
    }

    /**
     * Tags to exclude. Will trim each tag.
     *
     * @param excludes comma separated list of tags to exclude while running the tests.
     * @since Ant 1.10.7
     */
    public void setExcludeTags(final String excludes) {
        final StringTokenizer tokens = new StringTokenizer(excludes, ",");
        while (tokens.hasMoreTokens()) {
            excludeTags.add(tokens.nextToken().trim());
        }
    }

    private void preConfigure(final TestDefinition test) {
        if (test.getHaltOnFailure() == null) {
            test.setHaltOnFailure(this.haltOnFailure);
        }
        if (test.getFailureProperty() == null) {
            test.setFailureProperty(this.failureProperty);
        }
    }

    private void launchViaReflection(final InVMLaunch launchDefinition) {
        final ClassLoader cl = launchDefinition.getClassLoader();
        // instantiate a new TestExecutionContext instance using the launch definition's classloader
        final Class<?> testExecutionCtxClass;
        final Object testExecutionCtx;
        try {
            testExecutionCtxClass = Class.forName(TEST_EXECUTION_CONTEXT_CLASS_NAME, false, cl);
            final Class<?> klass = Class.forName(IN_VM_TEST_EXECUTION_CONTEXT_CLASS_NAME, false, cl);
            testExecutionCtx = klass.getConstructor(JUnitLauncherTask.class).newInstance(this);
        } catch (Exception e) {
            throw new BuildException("Failed to create a test execution context for in-vm tests", e);
        }
        // instantiate a new LauncherSupport instance using the launch definition's ClassLoader
        try {
            final Class<?> klass = Class.forName(LAUNCHER_SUPPORT_CLASS_NAME, false, cl);
            final Object launcherSupport = klass.getConstructor(LaunchDefinition.class, testExecutionCtxClass)
                    .newInstance(launchDefinition, testExecutionCtx);
            klass.getMethod("launch").invoke(launcherSupport);
        } catch (Exception e) {
            throw new BuildException("Failed to launch in-vm tests", e);
        }
    }

    private java.nio.file.Path dumpProjectProperties() throws IOException {
        final java.nio.file.Path propsPath = FileUtils.getFileUtils()
            .createTempFile(getProject(), null, "properties", null, true, true)
            .toPath();
        final Hashtable<String, Object> props = this.getProject().getProperties();
        final Properties projProperties = new Properties();
        projProperties.putAll(props);
        try (final OutputStream os = Files.newOutputStream(propsPath)) {
            // TODO: Is it always UTF-8?
            projProperties.store(os, StandardCharsets.UTF_8.name());
        }
        return propsPath;
    }

    private void forkTest(final TestDefinition test, final ForkDefinition forkDefinition,
                          final ForkedRepresentation forkedRepresentation,
                          final Optional<Long> deadlineNanos) {
        // create launch command
        final CommandlineJava commandlineJava = forkDefinition.generateCommandLine(this);
        if (this.classPath != null) {
            commandlineJava.createClasspath(getProject()).createPath().append(this.classPath);
        }
        final java.nio.file.Path projectPropsPath;
        try {
            projectPropsPath = dumpProjectProperties();
        } catch (IOException e) {
            throw new BuildException("Could not create the necessary properties file while forking a process" +
                    " for a test", e);
        }
        // --properties <path-to-properties-file>
        commandlineJava.createArgument().setValue(Constants.ARG_PROPERTIES);
        commandlineJava.createArgument().setValue(projectPropsPath.toAbsolutePath().toString());

        final java.nio.file.Path launchDefXmlPath = newLaunchDefinitionXml();
        try (final OutputStream os = Files.newOutputStream(launchDefXmlPath)) {
            final XMLStreamWriter writer = XMLOutputFactory.newFactory().createXMLStreamWriter(os, "UTF-8");
            try {
                writer.writeStartDocument();
                writer.writeStartElement(LD_XML_ELM_LAUNCH_DEF);
                if (this.printSummary) {
                    writer.writeAttribute(LD_XML_ATTR_PRINT_SUMMARY, "true");
                }
                if (this.haltOnFailure) {
                    writer.writeAttribute(LD_XML_ATTR_HALT_ON_FAILURE, "true");
                }
                if (this.includeTags.size() > 0) {
                    writer.writeAttribute(LD_XML_ATTR_INCLUDE_TAGS, commaSeparatedListElements(includeTags));
                }
                if (this.excludeTags.size() > 0) {
                    writer.writeAttribute(LD_XML_ATTR_EXCLUDE_TAGS, commaSeparatedListElements(excludeTags));
                }
                // task level listeners
                for (final ListenerDefinition listenerDef : this.listeners) {
                    if (!listenerDef.shouldUse(getProject())) {
                        continue;
                    }
                    // construct the listener definition argument
                    listenerDef.toForkedRepresentation(writer);
                }
                // test definition as XML
                forkedRepresentation.write(this, writer);
                writer.writeEndElement();
                writer.writeEndDocument();
            } finally {
                writer.close();
            }
        } catch (Exception e) {
            throw new BuildException("Failed to construct command line for test", e);
        }
        // --launch-definition <xml-file-path>
        commandlineJava.createArgument().setValue(Constants.ARG_LAUNCH_DEFINITION);
        commandlineJava.createArgument().setValue(launchDefXmlPath.toAbsolutePath().toString());

        // launch the process and wait for process to complete
        final int exitCode = executeForkedTest(forkDefinition, commandlineJava, deadlineNanos);
        switch (exitCode) {
            case Constants.FORK_EXIT_CODE_SUCCESS: {
                // success
                break;
            }
            case Constants.FORK_EXIT_CODE_EXCEPTION: {
                // process failed with some exception
                throw new BuildException("Forked test(s) failed with an exception");
            }
            case Constants.FORK_EXIT_CODE_TESTS_FAILED: {
                // test has failure(s)
                try {
                    if (test.getFailureProperty() != null) {
                        // if there are test failures and the test is configured to set a property in case
                        // of failure, then set the property to true
                        this.getProject().setNewProperty(test.getFailureProperty(), "true");
                    }
                } finally {
                    if (test.isHaltOnFailure()) {
                        // if the test is configured to halt on test failures, throw a build error
                        final String errorMessage;
                        if (test instanceof NamedTest) {
                            errorMessage = "Test " + ((NamedTest) test).getName() + " has failure(s)";
                        } else {
                            errorMessage = "Some test(s) have failure(s)";
                        }
                        throw new BuildException(errorMessage);
                    }
                }
                break;
            }
            case Constants.FORK_EXIT_CODE_TIMED_OUT: {
                // test has failure(s)
                try {
                    if (test.getFailureProperty() != null) {
                        // if there are test failures and the test is configured to set a property in case
                        // of failure, then set the property to true
                        this.getProject().setNewProperty(test.getFailureProperty(), "true");
                    }
                } finally {
                    if (test.isHaltOnFailure()) {
                        throw new BuildException(new TimeoutException("Forked test(s) timed out"));
                    } else {
                        log("Timeout occurred. Please note the time in the report does not reflect the time until the timeout.");
                    }
                }
            }
        }
    }

    private static String commaSeparatedListElements(final List<String> stringList) {
        return stringList.stream()
                .map(Object::toString)
                .collect(Collectors.joining(", "));
    }

    private int executeForkedTest(final ForkDefinition forkDefinition, final CommandlineJava commandlineJava,
                                  final Optional<Long> deadlineNanos) {
        final LogOutputStream outStream = new LogOutputStream(this, Project.MSG_INFO);
        final LogOutputStream errStream = new LogOutputStream(this, Project.MSG_WARN);
        final ExecuteWatchdog watchdog;
        if (deadlineNanos.isPresent()) {
            final long remainingNanos = deadlineNanos.get() - System.nanoTime();
            if (remainingNanos <= 0) {
                // already timed out
                return Constants.FORK_EXIT_CODE_TIMED_OUT;
            }
            final long timeoutInMillis = NANOSECONDS.toMillis(remainingNanos);
            if (timeoutInMillis == 0) {
                // already timed out
                return Constants.FORK_EXIT_CODE_TIMED_OUT;
            }
            watchdog = createExecuteWatchdog(timeoutInMillis);
        } else {
            watchdog = null; // no timeout specified
        }
        final Execute execute = new Execute(new PumpStreamHandler(outStream, errStream), watchdog);
        execute.setCommandline(commandlineJava.getCommandline());
        execute.setAntRun(getProject());
        if (forkDefinition.getDir() != null) {
            execute.setWorkingDirectory(Paths.get(forkDefinition.getDir()).toFile());
        }
        final Environment env = forkDefinition.getEnv();
        if (env != null && env.getVariables() != null) {
            execute.setEnvironment(env.getVariables());
        }
        log(commandlineJava.describeCommand(), Project.MSG_VERBOSE);
        int exitCode;
        try {
            exitCode = execute.execute();
        } catch (IOException e) {
            throw new BuildException("Process fork failed", e, getLocation());
        }
        return (watchdog != null && watchdog.killedProcess()) ? Constants.FORK_EXIT_CODE_TIMED_OUT : exitCode;
    }

    protected ExecuteWatchdog createExecuteWatchdog(long timeout) {
        return new ExecuteWatchdog(timeout);
    }

    private java.nio.file.Path newLaunchDefinitionXml() {
        return FileUtils.getFileUtils()
            .createTempFile(getProject(), null, ".xml", null, true, true)
            .toPath();
    }

    private final class InVMLaunch implements LaunchDefinition {

        private final List<TestDefinition> inVMTests;
        private final ClassLoader executionCL;

        private InVMLaunch(final List<TestDefinition> inVMTests) {
            this.inVMTests = inVMTests;
            this.executionCL = createInVMExecutionClassLoader();
        }

        @Override
        public List<TestDefinition> getTests() {
            return this.inVMTests;
        }

        @Override
        public List<ListenerDefinition> getListeners() {
            return listeners;
        }

        @Override
        public boolean isPrintSummary() {
            return printSummary;
        }

        @Override
        public boolean isHaltOnFailure() {
            return haltOnFailure;
        }

        @Override
        public List<String> getIncludeTags() {
            return includeTags;
        }

        @Override
        public List<String> getExcludeTags() {
            return excludeTags;
        }

        @Override
        public ClassLoader getClassLoader() {
            return this.executionCL;
        }

        private ClassLoader createInVMExecutionClassLoader() {
            final Path taskConfiguredClassPath = JUnitLauncherTask.this.classPath;
            if (taskConfiguredClassPath == null) {
                // no specific classpath configured for the task, so use the classloader
                // of this task
                return JUnitLauncherTask.class.getClassLoader();
            }
            // there's a classpath configured for the task.
            // we first check if the Ant runtime classpath has JUnit platform classes.
            // - if it does, then we use the Ant runtime classpath plus the task's configured classpath
            // with the traditional parent first loading.
            // - else (i.e. Ant runtime classpath doesn't have JUnit platform classes), then we
            // expect/assume the task's configured classpath to have the JUnit platform classes and we
            // then create a "overriding" classloader which prefers certain resources (specifically the classes
            // from org.apache.tools.ant.taskdefs.optional.junitlauncher package), from the task's
            // classpath, even if the Ant's runtime classpath has those resources.
            if (JUnitLauncherClassPathUtil.hasJUnitPlatformResources(JUnitLauncherTask.class.getClassLoader())) {
                return new AntClassLoader(JUnitLauncherTask.class.getClassLoader(), getProject(), taskConfiguredClassPath, true);
            }
            final Path cp = new Path(getProject());
            cp.add(taskConfiguredClassPath);
            // add the Ant runtime resources to this path
            JUnitLauncherClassPathUtil.addLauncherSupportResourceLocation(cp, JUnitLauncherTask.class.getClassLoader());
            return new TaskConfiguredPathClassLoader(JUnitLauncherTask.class.getClassLoader(), cp, getProject());
        }
    }

    /**
     * A {@link ClassLoader}, very similar to the {@link org.apache.tools.ant.util.SplitClassLoader},
     * which uses the {@link #TaskConfiguredPathClassLoader(ClassLoader, Path, Project) configured Path}
     * to load a class, if the class belongs to the {@code org.apache.tools.ant.taskdefs.optional.junitlauncher}
     * package.
     * <p>
     * While looking for classes belonging to the {@code org.apache.tools.ant.taskdefs.optional.junitlauncher}
     * package, this classloader completely ignores Ant runtime classpath, even if that classpath has
     * those classes. This allows the users of this classloader to use a custom location and thus more control over
     * where these classes reside, when running the {@code junitlauncher} task
     */
    private final class TaskConfiguredPathClassLoader extends AntClassLoader {

        /**
         * @param parent  ClassLoader
         * @param path    Path
         * @param project Project
         */
        private TaskConfiguredPathClassLoader(ClassLoader parent, Path path, Project project) {
            super(parent, project, path, true);
        }

        // forceLoadClass is not convenient here since it would not
        // properly deal with inner classes of these classes.
        @Override
        protected synchronized Class<?> loadClass(String classname, boolean resolve)
                throws ClassNotFoundException {
            Class<?> theClass = findLoadedClass(classname);
            if (theClass != null) {
                return theClass;
            }
            final String packageName = classname.contains(".") ? classname.substring(0, classname.lastIndexOf('.'))
                    : "";
            if (packageName.equals("org.apache.tools.ant.taskdefs.optional.junitlauncher")) {
                theClass = findClass(classname);
                if (resolve) {
                    resolveClass(theClass);
                }
                return theClass;
            }
            return super.loadClass(classname, resolve);
        }
    }
}
