/*
 * 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.maven.surefire.junitplatform;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.stream.Stream;

import org.apache.maven.surefire.api.report.OutputReportEntry;
import org.apache.maven.surefire.api.report.RunMode;
import org.apache.maven.surefire.api.report.SafeThrowable;
import org.apache.maven.surefire.api.report.SimpleReportEntry;
import org.apache.maven.surefire.api.report.StackTraceWriter;
import org.apache.maven.surefire.api.report.TestOutputReceiver;
import org.apache.maven.surefire.api.report.TestOutputReportEntry;
import org.apache.maven.surefire.api.report.TestReportListener;
import org.apache.maven.surefire.report.ClassMethodIndexer;
import org.apache.maven.surefire.report.PojoStackTraceWriter;
import org.apache.maven.surefire.report.RunModeSetter;
import org.junit.platform.engine.TestExecutionResult;
import org.junit.platform.engine.TestSource;
import org.junit.platform.engine.support.descriptor.ClassSource;
import org.junit.platform.engine.support.descriptor.MethodSource;
import org.junit.platform.launcher.TestExecutionListener;
import org.junit.platform.launcher.TestIdentifier;
import org.junit.platform.launcher.TestPlan;

import static java.util.Collections.emptyMap;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static org.apache.maven.surefire.api.util.internal.ObjectUtils.systemProps;
import static org.apache.maven.surefire.shared.lang3.StringUtils.isNotBlank;
import static org.junit.platform.engine.TestExecutionResult.Status.FAILED;

/**
 * @since 2.22.0
 */
final class RunListenerAdapter implements TestExecutionListener, TestOutputReceiver<OutputReportEntry>, RunModeSetter {
    private final ClassMethodIndexer classMethodIndexer = new ClassMethodIndexer();
    private final ConcurrentMap<TestIdentifier, Long> testStartTime = new ConcurrentHashMap<>();
    private final ConcurrentMap<TestIdentifier, TestExecutionResult> failures = new ConcurrentHashMap<>();
    private final ConcurrentMap<String, TestIdentifier> runningTestIdentifiersByUniqueId = new ConcurrentHashMap<>();
    private final TestReportListener<TestOutputReportEntry> runListener;
    private volatile TestPlan testPlan;
    private volatile RunMode runMode;

    RunListenerAdapter(TestReportListener<TestOutputReportEntry> runListener) {
        this.runListener = runListener;
    }

    @Override
    public void setRunMode(RunMode runMode) {
        this.runMode = runMode;
    }

    @Override
    public void testPlanExecutionStarted(TestPlan testPlan) {
        this.testPlan = testPlan;
    }

    @Override
    public void testPlanExecutionFinished(TestPlan testPlan) {
        this.testPlan = null;
        testStartTime.clear();
    }

    @Override
    public void executionStarted(TestIdentifier testIdentifier) {
        runningTestIdentifiersByUniqueId.put(testIdentifier.getUniqueId(), testIdentifier);

        if (testIdentifier.isContainer()
                && testIdentifier
                        .getSource()
                        .filter(ClassSource.class::isInstance)
                        .isPresent()) {
            testStartTime.put(testIdentifier, System.currentTimeMillis());
            runListener.testSetStarting(createReportEntry(testIdentifier));
        } else if (testIdentifier.isTest()) {
            testStartTime.put(testIdentifier, System.currentTimeMillis());
            runListener.testStarting(createReportEntry(testIdentifier));
        }
    }

    @Override
    public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
        boolean isClass = testIdentifier.isContainer()
                && testIdentifier
                        .getSource()
                        .filter(ClassSource.class::isInstance)
                        .isPresent();

        boolean isTest = testIdentifier.isTest();

        boolean failed = testExecutionResult.getStatus() == FAILED;

        boolean isAssertionError = testExecutionResult
                .getThrowable()
                .filter(AssertionError.class::isInstance)
                .isPresent();

        boolean isRootContainer =
                testIdentifier.isContainer() && !testIdentifier.getParentId().isPresent();

        if (failed || isClass || isTest) {
            Integer elapsed = computeElapsedTime(testIdentifier);
            switch (testExecutionResult.getStatus()) {
                case ABORTED:
                    if (isTest) {
                        runListener.testAssumptionFailure(
                                createReportEntry(testIdentifier, testExecutionResult, elapsed));
                    } else {
                        runListener.testSetCompleted(
                                createReportEntry(testIdentifier, testExecutionResult, systemProps(), null, elapsed));
                    }
                    break;
                case FAILED:
                    String reason =
                            safeGetMessage(testExecutionResult.getThrowable().orElse(null));
                    SimpleReportEntry reportEntry =
                            createReportEntry(testIdentifier, testExecutionResult, reason, elapsed);
                    if (isAssertionError) {
                        runListener.testFailed(reportEntry);
                    } else {
                        runListener.testError(reportEntry);
                    }
                    if (isClass || isRootContainer) {
                        runListener.testSetCompleted(
                                createReportEntry(testIdentifier, null, systemProps(), null, elapsed));
                    }
                    failures.put(testIdentifier, testExecutionResult);
                    break;
                default:
                    if (isTest) {
                        runListener.testSucceeded(createReportEntry(testIdentifier, null, elapsed));
                    } else {
                        runListener.testSetCompleted(
                                createReportEntry(testIdentifier, null, systemProps(), null, elapsed));
                    }
            }
        }

        runningTestIdentifiersByUniqueId.remove(testIdentifier.getUniqueId());
    }

    private Integer computeElapsedTime(TestIdentifier testIdentifier) {
        Long startTime = testStartTime.remove(testIdentifier);
        long endTime = System.currentTimeMillis();
        return startTime == null ? null : (int) (endTime - startTime);
    }

    private Stream<TestIdentifier> collectAllTestIdentifiersInHierarchy(TestIdentifier testIdentifier) {
        return testIdentifier
                .getParentId()
                .map(runningTestIdentifiersByUniqueId::get)
                .map(parentTestIdentifier -> Stream.concat(
                        Stream.of(parentTestIdentifier), collectAllTestIdentifiersInHierarchy(parentTestIdentifier)))
                .orElseGet(Stream::empty);
    }

    private String safeGetMessage(Throwable throwable) {
        try {
            SafeThrowable t = throwable == null ? null : new SafeThrowable(throwable);
            return t == null ? null : t.getMessage();
        } catch (Throwable t) {
            return t.getMessage();
        }
    }

    @Override
    public void executionSkipped(TestIdentifier testIdentifier, String reason) {
        boolean isClass = testIdentifier.isContainer()
                && testIdentifier
                        .getSource()
                        .filter(ClassSource.class::isInstance)
                        .isPresent();

        testStartTime.remove(testIdentifier);

        if (isClass) {
            SimpleReportEntry report = createReportEntry(testIdentifier);
            runListener.testSetStarting(report);
            for (TestIdentifier child : testPlan.getChildren(testIdentifier)) {
                runListener.testSkipped(createReportEntry(child, null, emptyMap(), reason, null));
            }
            runListener.testSetCompleted(report);
        } else {
            runListener.testSkipped(createReportEntry(testIdentifier, null, emptyMap(), reason, null));
        }
    }

    private SimpleReportEntry createReportEntry(
            TestIdentifier testIdentifier,
            TestExecutionResult testExecutionResult,
            Map<String, String> systemProperties,
            String reason,
            Integer elapsedTime) {
        String[] classMethodName = toClassMethodName(testIdentifier);
        String className = classMethodName[0];
        String classText = classMethodName[1];
        if (Objects.equals(className, classText)) {
            classText = null;
        }
        boolean failed = testExecutionResult == null || testExecutionResult.getStatus() == FAILED;
        String methodName = failed || testIdentifier.isTest() ? classMethodName[2] : null;
        String methodText = failed || testIdentifier.isTest() ? classMethodName[3] : null;
        if (Objects.equals(methodName, methodText)) {
            methodText = null;
        }
        StackTraceWriter stw =
                testExecutionResult == null ? null : toStackTraceWriter(className, methodName, testExecutionResult);
        return new SimpleReportEntry(
                runMode,
                classMethodIndexer.indexClassMethod(className, methodName),
                className,
                classText,
                methodName,
                methodText,
                stw,
                elapsedTime,
                reason,
                systemProperties);
    }

    private SimpleReportEntry createReportEntry(TestIdentifier testIdentifier) {
        return createReportEntry(testIdentifier, null, null);
    }

    private SimpleReportEntry createReportEntry(
            TestIdentifier testIdentifier, TestExecutionResult testExecutionResult, Integer elapsedTime) {
        return createReportEntry(testIdentifier, testExecutionResult, emptyMap(), null, elapsedTime);
    }

    private SimpleReportEntry createReportEntry(
            TestIdentifier testIdentifier,
            TestExecutionResult testExecutionResult,
            String reason,
            Integer elapsedTime) {
        return createReportEntry(testIdentifier, testExecutionResult, emptyMap(), reason, elapsedTime);
    }

    private StackTraceWriter toStackTraceWriter(
            String realClassName, String realMethodName, TestExecutionResult testExecutionResult) {
        switch (testExecutionResult.getStatus()) {
            case ABORTED:
            case FAILED:
                // Failed tests must have a StackTraceWriter, otherwise Surefire will fail
                Throwable exception = testExecutionResult.getThrowable().orElse(null);
                return toStackTraceWriter(realClassName, realMethodName, exception);
            default:
                return testExecutionResult
                        .getThrowable()
                        .map(t -> toStackTraceWriter(realClassName, realMethodName, t))
                        .orElse(null);
        }
    }

    private StackTraceWriter toStackTraceWriter(String realClassName, String realMethodName, Throwable throwable) {
        return new PojoStackTraceWriter(realClassName, realMethodName, throwable);
    }

    /**
     * <ul>
     *     <li>[0] class name - used in stacktrace parser</li>
     *     <li>[1] class display name</li>
     *     <li>[2] method signature - used in stacktrace parser</li>
     *     <li>[3] method display name</li>
     * </ul>
     *
     * @param testIdentifier a class or method
     * @return 4 elements string array
     */
    private String[] toClassMethodName(TestIdentifier testIdentifier) {
        Optional<TestSource> testSource = testIdentifier.getSource();
        String display = testIdentifier.getDisplayName();

        if (testSource.filter(MethodSource.class::isInstance).isPresent()) {
            MethodSource methodSource = testSource.map(MethodSource.class::cast).get();
            String realClassName = methodSource.getClassName();

            String[] source = collectAllTestIdentifiersInHierarchy(testIdentifier)
                    .filter(i ->
                            i.getSource().map(ClassSource.class::isInstance).orElse(false))
                    .findFirst()
                    .map(this::toClassMethodName)
                    .map(s -> new String[] {s[0], s[1]})
                    .orElse(new String[] {realClassName, realClassName});

            String parentDisplay = collectAllTestIdentifiersInHierarchy(testIdentifier)
                    .filter(identifier -> identifier
                            .getSource()
                            .filter(MethodSource.class::isInstance)
                            .isPresent())
                    .map(TestIdentifier::getDisplayName)
                    .collect(joining(" "));

            boolean needsSpaceSeparator = isNotBlank(parentDisplay) && !display.startsWith("[");
            String methodDisplay = parentDisplay + (needsSpaceSeparator ? " " : "") + display;

            boolean isParameterized = isNotBlank(methodSource.getMethodParameterTypes());
            boolean hasParameterizedParent = collectAllTestIdentifiersInHierarchy(testIdentifier)
                    .filter(identifier -> !identifier.getSource().isPresent())
                    .map(TestIdentifier::getLegacyReportingName)
                    .anyMatch(legacyReportingName -> legacyReportingName.matches("^\\[.+]$"));
            boolean isTestTemplate = testIdentifier.getLegacyReportingName().matches("^.*\\[\\d+]$");

            boolean parameterized = isParameterized || hasParameterizedParent || isTestTemplate;
            String methodName = methodSource.getMethodName();
            String description = testIdentifier.getLegacyReportingName();
            boolean equalDescriptions = methodDisplay.equals(description);
            boolean hasLegacyDescription = description.startsWith(methodName + '(');
            boolean hasDisplayName = !equalDescriptions || !hasLegacyDescription;
            String methodDesc = parameterized ? description : methodName;
            String methodDisp = hasDisplayName ? methodDisplay : methodDesc;

            // The behavior of methods getLegacyReportingName() and getDisplayName().
            //     junit4    ||  legacy  |  display
            // ==============||==========|==========
            //     normal    ||     m    |     m
            //     param     ||   m[0]   |   m[0]
            //  param+displ  || m[displ] | m[displ]

            //     junit5    ||  legacy  |  display
            // ==============||==========|==========
            //    normal     ||    m()   |    m()
            //  normal+displ ||    m()   |   displ
            //     param     ||  m()[1]  | [1] <param>
            //  param+displ  ||  m()[1]  |   displ

            return new String[] {source[0], source[1], methodDesc, methodDisp};
        } else if (testSource.filter(ClassSource.class::isInstance).isPresent()) {
            List<String> parentClassDisplays = collectAllTestIdentifiersInHierarchy(testIdentifier)
                    .filter(identifier -> identifier
                            .getSource()
                            .filter(ClassSource.class::isInstance)
                            .isPresent())
                    .map(TestIdentifier::getDisplayName)
                    .collect(toList());

            Collections.reverse(parentClassDisplays);
            String classDisplay = Stream.concat(parentClassDisplays.stream(), Stream.of(display))
                    .collect(joining(" "));

            ClassSource classSource = testSource.map(ClassSource.class::cast).get();
            String className = classSource.getClassName();
            String simpleClassName = className.substring(1 + className.lastIndexOf('.'));
            String source = classDisplay.replace(' ', '$').equals(simpleClassName) ? className : classDisplay;
            return new String[] {className, source, null, null};
        } else {
            String source = testPlan.getParent(testIdentifier)
                    .map(TestIdentifier::getDisplayName)
                    .orElse(display);
            return new String[] {source, source, display, display};
        }
    }

    /**
     * @return Map of tests that failed.
     */
    Map<TestIdentifier, TestExecutionResult> getFailures() {
        return failures;
    }

    boolean hasFailingTests() {
        return !getFailures().isEmpty();
    }

    void reset() {
        getFailures().clear();
        testPlan = null;
    }

    @Override
    public void writeTestOutput(OutputReportEntry reportEntry) {
        Long testRunId = classMethodIndexer.getLocalIndex();
        runListener.writeTestOutput(new TestOutputReportEntry(reportEntry, runMode, testRunId));
    }
}
