package org.keycloak.testsuite.util.junit;

import org.apache.commons.configuration.PropertiesConfiguration;
import org.jboss.logging.Logger;
import org.junit.Ignore;
import org.junit.runner.Description;
import org.junit.runner.Result;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.nio.charset.Charset;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;

/**
 * Aggregates jUnit test results into a single report - XML file.
 */
public class AggregateResultsReporter extends RunListener {

    private static final Logger LOGGER = Logger.getLogger(AggregateResultsReporter.class);

    private final Document xml;
    private final File reportFile;
    private final boolean working;

    private final AtomicInteger tests = new AtomicInteger(0);
    private final AtomicInteger errors = new AtomicInteger(0);
    private final AtomicInteger failures = new AtomicInteger(0);
    private final AtomicInteger ignored = new AtomicInteger(0);
    private final AtomicLong suiteStartTime = new AtomicLong(0L);

    private final AtomicReference<Element> testsuite = new AtomicReference<Element>();

    private final Map<String, Long> testTimes = new HashMap<String, Long>();

    public AggregateResultsReporter() {
        boolean working = true;
        Document xml = null;
        try {
            xml = createEmptyDocument();
        } catch (ParserConfigurationException ex) {
            LOGGER.error("Failed to create XML DOM - reporting will not be done", ex);
            working = false;
        }

        File reportFile = null;
        try {
            reportFile = createReportFile();
        } catch (Exception ex) {
            LOGGER.error("Failed to create log file - reporting will not be done", ex);
            working = false;
        }

        this.working = working;
        this.xml = xml;
        this.reportFile = reportFile;
    }

    private Document createEmptyDocument() throws ParserConfigurationException {
        DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
        return builder.newDocument();
    }

    private File createReportFile() throws Exception {
        String logDirPath = null;

        try {
            PropertiesConfiguration config = new PropertiesConfiguration(System.getProperty("testsuite.constants"));
            config.setThrowExceptionOnMissing(true);
            logDirPath = config.getString("log-dir");
        } catch (Exception e) {
            logDirPath = System.getProperty("project.build.directory");
            if (logDirPath == null) {
                throw new RuntimeException("Could not determine the path to the log directory.");
            }
            logDirPath += File.separator + "surefire-reports";
        }

        final File logDir = new File(logDirPath);
        logDir.mkdirs();

        final File reportFile = new File(logDir, "junit-single-report.xml").getAbsoluteFile();
        reportFile.createNewFile();

        return reportFile;
    }

    @Override
    public void testRunStarted(Description description) throws Exception {
        if (working) {
            suiteStartTime.set(System.currentTimeMillis());

            Element testsuite = xml.createElement("testsuite");

            if (description.getChildren().size() == 1) {
                testsuite.setAttribute("name", safeString(description.getChildren().get(0).getDisplayName()));
            }

            xml.appendChild(testsuite);
            this.testsuite.set(testsuite);
            writeXml();
        }
    }

    @Override
    public void testStarted(Description description) throws Exception {
        if (working) {
            testTimes.put(description.getDisplayName(), System.currentTimeMillis());
        }
    }

    @Override
    public void testFinished(Description description) throws Exception {
        if (working) {
            if (testTimes.containsKey(description.getDisplayName())) {
                testsuite.get().appendChild(createTestCase(description));
                writeXml();
            }
        }
    }

    @Override
    public void testAssumptionFailure(Failure failure) {
        if (working) {
            ignored.incrementAndGet();

            Element testcase = createTestCase(failure.getDescription());
            Element skipped = xml.createElement("skipped");
            skipped.setAttribute("message", safeString(failure.getMessage()));

            testcase.appendChild(skipped);

            testsuite.get().appendChild(testcase);
            writeXml();
        }
    }

    @Override
    public void testFailure(Failure failure) throws Exception {
        if (working) {
            if (failure.getDescription().getMethodName() == null) {
                // before class failed
                for (Description child : failure.getDescription().getChildren()) {
                    // mark all methods failed
                    testFailure(new Failure(child, failure.getException()));
                }
            } else {
                // normal failure
                Element testcase = createTestCase(failure.getDescription());

                Element element;
                if (failure.getException() instanceof AssertionError) {
                    failures.incrementAndGet();
                    element = xml.createElement("failure");
                } else {
                    errors.incrementAndGet();
                    element = xml.createElement("error");
                }

                testcase.appendChild(element);

                element.setAttribute("type", safeString(failure.getException().getClass().getName()));
                element.setAttribute("message", safeString(failure.getMessage()));
                element.appendChild(xml.createCDATASection(safeString(failure.getTrace())));

                testsuite.get().appendChild(testcase);
                writeXml();
            }
        }
    }

    @Override
    public void testIgnored(Description description) throws Exception {
        if (working) {
            ignored.incrementAndGet();

            Element testcase = createTestCase(description);

            Element skipped = xml.createElement("skipped");
            skipped.setAttribute("message", safeString(description.getAnnotation(Ignore.class).value()));

            testcase.appendChild(skipped);

            testsuite.get().appendChild(testcase);
            writeXml();
        }
    }

    @Override
    public void testRunFinished(Result result) throws Exception {
        if (working) {
            writeXml();
        }
    }

    private void writeXml() {
        Element testsuite = this.testsuite.get();

        testsuite.setAttribute("tests", Integer.toString(tests.get()));
        testsuite.setAttribute("errors", Integer.toString(errors.get()));
        testsuite.setAttribute("skipped", Integer.toString(ignored.get()));
        testsuite.setAttribute("failures", Integer.toString(failures.get()));
        testsuite.setAttribute("time", computeTestTime(suiteStartTime.get()));

        try {
            Writer writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(reportFile, false), Charset.forName("UTF-8")));
            try {
                Transformer t = TransformerFactory.newInstance().newTransformer();
                t.setOutputProperty(OutputKeys.INDENT, "yes");
                t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
                t.transform(new DOMSource(xml), new StreamResult(writer));
            } catch (TransformerConfigurationException ex) {
                LOGGER.error("Misconfigured transformer", ex);
            } catch (TransformerException ex) {
                LOGGER.error("Unable to save XML file", ex);
            } finally {
                writer.close();
            }
        } catch (IOException ex) {
            LOGGER.warn("Unable to open report file", ex);
        }
    }

    private String computeTestTime(Long startTime) {
        if (startTime == null) {
            return "0";
        } else {
            long amount = System.currentTimeMillis() - startTime;
            return String.format("%.3f", amount / 1000F);
        }
    }

    private Element createTestCase(Description description) {
        tests.incrementAndGet();

        Element testcase = xml.createElement("testcase");

        testcase.setAttribute("name", safeString(description.getMethodName()));
        testcase.setAttribute("classname", safeString(description.getClassName()));
        testcase.setAttribute("time", computeTestTime(testTimes.remove(description.getDisplayName())));

        return testcase;
    }

    private String safeString(String input) {
        if (input == null) {
            return "null";
        }

        return input
                // first remove color coding (all of it)
                .replaceAll("\u001b\\[\\d+m", "")
                // then remove control characters that are not whitespaces
                .replaceAll("[\\p{Cntrl}&&[^\\p{Space}]]", "");
    }
}
