package org.apache.tools.ant.taskdefs.optional.junitlauncher;

import org.apache.tools.ant.util.DOMElementWriter;
import org.apache.tools.ant.util.DateUtils;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.TestSource;
import org.junit.platform.engine.reporting.ReportEntry;
import org.junit.platform.engine.support.descriptor.ClassSource;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.TestPlan;

import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Reader;
import java.util.Date;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

/**
 * A {@link TestResultFormatter} which generates an XML report of the tests. The generated XML reports
 * conforms to the schema of the XML that was generated by the {@code junit} task's XML
 * report formatter and can be used by the {@code junitreport} task
 */
class LegacyXmlResultFormatter extends AbstractJUnitResultFormatter implements TestResultFormatter {

    private static final double ONE_SECOND = 1000.0;

    private OutputStream outputStream;
    private final Map<TestIdentifier, Stats> testIds = new ConcurrentHashMap<>();
    private final Map<TestIdentifier, Optional<String>> skipped = new ConcurrentHashMap<>();
    private final Map<TestIdentifier, Optional<Throwable>> failed = new ConcurrentHashMap<>();
    private final Map<TestIdentifier, Optional<Throwable>> aborted = new ConcurrentHashMap<>();

    private TestPlan testPlan;
    private long testPlanStartedAt = -1;
    private long testPlanEndedAt = -1;
    private final AtomicLong numTestsRun = new AtomicLong(0);
    private final AtomicLong numTestsFailed = new AtomicLong(0);
    private final AtomicLong numTestsSkipped = new AtomicLong(0);
    private final AtomicLong numTestsAborted = new AtomicLong(0);


    @Override
    public void testPlanExecutionStarted(final TestPlan testPlan) {
        this.testPlan = testPlan;
        this.testPlanStartedAt = System.currentTimeMillis();
    }

    @Override
    public void testPlanExecutionFinished(final TestPlan testPlan) {
        this.testPlanEndedAt = System.currentTimeMillis();
        // format and print out the result
        try {
            new XMLReportWriter().write();
        } catch (IOException | XMLStreamException e) {
            handleException(e);
            return;
        }
    }

    @Override
    public void dynamicTestRegistered(final TestIdentifier testIdentifier) {
        // nothing to do
    }

    @Override
    public void executionSkipped(final TestIdentifier testIdentifier, final String reason) {
        final long currentTime = System.currentTimeMillis();
        this.numTestsSkipped.incrementAndGet();
        this.skipped.put(testIdentifier, Optional.ofNullable(reason));
        // a skipped test is considered started and ended now
        final Stats stats = new Stats(testIdentifier, currentTime);
        stats.endedAt = currentTime;
        this.testIds.put(testIdentifier, stats);
    }

    @Override
    public void executionStarted(final TestIdentifier testIdentifier) {
        final long currentTime = System.currentTimeMillis();
        this.testIds.putIfAbsent(testIdentifier, new Stats(testIdentifier, currentTime));
        if (testIdentifier.isTest()) {
            this.numTestsRun.incrementAndGet();
        }
    }

    @Override
    public void executionFinished(final TestIdentifier testIdentifier, final TestExecutionResult testExecutionResult) {
        final long currentTime = System.currentTimeMillis();
        final Stats stats = this.testIds.get(testIdentifier);
        if (stats != null) {
            stats.endedAt = currentTime;
        }
        switch (testExecutionResult.getStatus()) {
            case SUCCESSFUL: {
                break;
            }
            case ABORTED: {
                this.numTestsAborted.incrementAndGet();
                this.aborted.put(testIdentifier, testExecutionResult.getThrowable());
                break;
            }
            case FAILED: {
                this.numTestsFailed.incrementAndGet();
                this.failed.put(testIdentifier, testExecutionResult.getThrowable());
                break;
            }
        }
    }

    @Override
    public void reportingEntryPublished(final TestIdentifier testIdentifier, final ReportEntry entry) {
        // nothing to do
    }

    @Override
    public void setDestination(final OutputStream os) {
        this.outputStream = os;
    }

    private final class Stats {
        private final TestIdentifier testIdentifier;
        private final long startedAt;
        private long endedAt;

        private Stats(final TestIdentifier testIdentifier, final long startedAt) {
            this.testIdentifier = testIdentifier;
            this.startedAt = startedAt;
        }
    }

    private final class XMLReportWriter {

        private static final String ELEM_TESTSUITE = "testsuite";
        private static final String ELEM_PROPERTIES = "properties";
        private static final String ELEM_PROPERTY = "property";
        private static final String ELEM_TESTCASE = "testcase";
        private static final String ELEM_SKIPPED = "skipped";
        private static final String ELEM_FAILURE = "failure";
        private static final String ELEM_ABORTED = "aborted";
        private static final String ELEM_SYSTEM_OUT = "system-out";
        private static final String ELEM_SYSTEM_ERR = "system-err";


        private static final String ATTR_CLASSNAME = "classname";
        private static final String ATTR_NAME = "name";
        private static final String ATTR_VALUE = "value";
        private static final String ATTR_TIME = "time";
        private static final String ATTR_TIMESTAMP = "timestamp";
        private static final String ATTR_NUM_ABORTED = "aborted";
        private static final String ATTR_NUM_FAILURES = "failures";
        private static final String ATTR_NUM_TESTS = "tests";
        private static final String ATTR_NUM_SKIPPED = "skipped";
        private static final String ATTR_MESSAGE = "message";
        private static final String ATTR_TYPE = "type";

        void write() throws XMLStreamException, IOException {
            final XMLStreamWriter writer = XMLOutputFactory.newFactory().createXMLStreamWriter(outputStream, "UTF-8");
            try {
                writer.writeStartDocument();
                writeTestSuite(writer);
                writer.writeEndDocument();
            } finally {
                writer.close();
            }
        }

        void writeTestSuite(final XMLStreamWriter writer) throws XMLStreamException, IOException {
            // write the testsuite element
            writer.writeStartElement(ELEM_TESTSUITE);
            final String testsuiteName = determineTestSuiteName();
            writer.writeAttribute(ATTR_NAME, testsuiteName);
            // time taken for the tests execution
            writer.writeAttribute(ATTR_TIME, String.valueOf((testPlanEndedAt - testPlanStartedAt) / ONE_SECOND));
            // add the timestamp of report generation
            final String timestamp = DateUtils.format(new Date(), DateUtils.ISO8601_DATETIME_PATTERN);
            writer.writeAttribute(ATTR_TIMESTAMP, timestamp);
            writer.writeAttribute(ATTR_NUM_TESTS, String.valueOf(numTestsRun.longValue()));
            writer.writeAttribute(ATTR_NUM_FAILURES, String.valueOf(numTestsFailed.longValue()));
            writer.writeAttribute(ATTR_NUM_SKIPPED, String.valueOf(numTestsSkipped.longValue()));
            writer.writeAttribute(ATTR_NUM_ABORTED, String.valueOf(numTestsAborted.longValue()));

            // write the properties
            writeProperties(writer);
            // write the tests
            writeTestCase(writer);
            writeSysOut(writer);
            writeSysErr(writer);
            // end the testsuite
            writer.writeEndElement();
        }

        void writeProperties(final XMLStreamWriter writer) throws XMLStreamException {
            final Properties properties = LegacyXmlResultFormatter.this.context.getProperties();
            if (properties == null || properties.isEmpty()) {
                return;
            }
            writer.writeStartElement(ELEM_PROPERTIES);
            for (final String prop : properties.stringPropertyNames()) {
                writer.writeStartElement(ELEM_PROPERTY);
                writer.writeAttribute(ATTR_NAME, prop);
                writer.writeAttribute(ATTR_VALUE, properties.getProperty(prop));
                writer.writeEndElement();
            }
            writer.writeEndElement();
        }

        void writeTestCase(final XMLStreamWriter writer) throws XMLStreamException {
            for (final Map.Entry<TestIdentifier, Stats> entry : testIds.entrySet()) {
                final TestIdentifier testId = entry.getKey();
                if (!testId.isTest()) {
                    // only interested in test methods
                    continue;
                }
                // find the parent class of this test method
                final Optional<TestIdentifier> parent = testPlan.getParent(testId);
                if (!parent.isPresent() || !parent.get().getSource().isPresent()) {
                    // we need to know the parent test class, else we aren't interested
                    continue;
                }
                final TestSource parentSource = parent.get().getSource().get();
                if (!(parentSource instanceof ClassSource)) {
                    continue;
                }
                final String classname = ((ClassSource) parentSource).getClassName();
                writer.writeStartElement(ELEM_TESTCASE);
                writer.writeAttribute(ATTR_CLASSNAME, classname);
                writer.writeAttribute(ATTR_NAME, testId.getDisplayName());
                final Stats stats = entry.getValue();
                writer.writeAttribute(ATTR_TIME, String.valueOf((stats.endedAt - stats.startedAt) / ONE_SECOND));
                // skipped element if the test was skipped
                writeSkipped(writer, testId);
                // failed element if the test failed
                writeFailed(writer, testId);
                // aborted element if the test was aborted
                writeAborted(writer, testId);

                writer.writeEndElement();
            }
        }

        private void writeSkipped(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException {
            if (!skipped.containsKey(testIdentifier)) {
                return;
            }
            writer.writeStartElement(ELEM_SKIPPED);
            final Optional<String> reason = skipped.get(testIdentifier);
            if (reason.isPresent()) {
                writer.writeAttribute(ATTR_MESSAGE, reason.get());
            }
            writer.writeEndElement();
        }

        private void writeFailed(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException {
            if (!failed.containsKey(testIdentifier)) {
                return;
            }
            writer.writeStartElement(ELEM_FAILURE);
            final Optional<Throwable> cause = failed.get(testIdentifier);
            if (cause.isPresent()) {
                final Throwable t = cause.get();
                final String message = t.getMessage();
                if (message != null && !message.trim().isEmpty()) {
                    writer.writeAttribute(ATTR_MESSAGE, message);
                }
                writer.writeAttribute(ATTR_TYPE, t.getClass().getName());
            }
            writer.writeEndElement();
        }

        private void writeAborted(final XMLStreamWriter writer, final TestIdentifier testIdentifier) throws XMLStreamException {
            if (!aborted.containsKey(testIdentifier)) {
                return;
            }
            writer.writeStartElement(ELEM_ABORTED);
            final Optional<Throwable> cause = aborted.get(testIdentifier);
            if (cause.isPresent()) {
                final Throwable t = cause.get();
                final String message = t.getMessage();
                if (message != null && !message.trim().isEmpty()) {
                    writer.writeAttribute(ATTR_MESSAGE, message);
                }
                writer.writeAttribute(ATTR_TYPE, t.getClass().getName());
            }
            writer.writeEndElement();
        }

        private void writeSysOut(final XMLStreamWriter writer) throws XMLStreamException, IOException {
            if (!LegacyXmlResultFormatter.this.hasSysOut()) {
                return;
            }
            writer.writeStartElement(ELEM_SYSTEM_OUT);
            try (final Reader reader = LegacyXmlResultFormatter.this.getSysOutReader()) {
                writeCharactersFrom(reader, writer);
            }
            writer.writeEndElement();
        }

        private void writeSysErr(final XMLStreamWriter writer) throws XMLStreamException, IOException {
            if (!LegacyXmlResultFormatter.this.hasSysErr()) {
                return;
            }
            writer.writeStartElement(ELEM_SYSTEM_ERR);
            try (final Reader reader = LegacyXmlResultFormatter.this.getSysErrReader()) {
                writeCharactersFrom(reader, writer);
            }
            writer.writeEndElement();
        }

        private void writeCharactersFrom(final Reader reader, final XMLStreamWriter writer) throws IOException, XMLStreamException {
            final char[] chars = new char[1024];
            int numRead = -1;
            while ((numRead = reader.read(chars)) != -1) {
                // although it's called a DOMElementWriter, the encode method is just a
                // straight forward XML util method which doesn't concern about whether
                // DOM, SAX, StAX semantics.
                // TODO: Perhaps make it a static method
                final String encoded = new DOMElementWriter().encode(new String(chars, 0, numRead));
                writer.writeCharacters(encoded);
            }
        }

        private String determineTestSuiteName() {
            // this is really a hack to try and match the expectations of the XML report in JUnit4.x
            // world. In JUnit5, the TestPlan doesn't have a name and a TestPlan (for which this is a
            // listener) can have numerous tests within it
            final Set<TestIdentifier> roots = testPlan.getRoots();
            if (roots.isEmpty()) {
                return "UNKNOWN";
            }
            for (final TestIdentifier root : roots) {
                final Optional<ClassSource> classSource = findFirstClassSource(root);
                if (classSource.isPresent()) {
                    return classSource.get().getClassName();
                }
            }
            return "UNKNOWN";
        }

        private Optional<ClassSource> findFirstClassSource(final TestIdentifier root) {
            if (root.getSource().isPresent()) {
                final TestSource source = root.getSource().get();
                if (source instanceof ClassSource) {
                    return Optional.of((ClassSource) source);
                }
            }
            for (final TestIdentifier child : testPlan.getChildren(root)) {
                final Optional<ClassSource> classSource = findFirstClassSource(child);
                if (classSource.isPresent()) {
                    return classSource;
                }
            }
            return Optional.empty();
        }
    }

}
