package cz.moneta.test.harness;

import cz.moneta.test.harness.annotations.*;
import cz.moneta.test.harness.config.ConfigProvider;
import cz.moneta.test.harness.constants.HarnessConfigConstants;
import cz.moneta.test.harness.context.BaseStoreAccessor;
import cz.moneta.test.harness.context.StoreAccessor;
import cz.moneta.test.harness.data.Browser;
import cz.moneta.test.harness.endpoints.Endpoint;
import cz.moneta.test.harness.endpoints.MobileEndpoint;
import cz.moneta.test.harness.endpoints.WebEndpoint;
import cz.moneta.test.harness.endpoints.greenscreen.GreenScreenEndpoint;
import cz.moneta.test.harness.endpoints.jira.JiraTestResultPublisher;
import cz.moneta.test.harness.exception.BeforeAllHarnessException;
import cz.moneta.test.harness.exception.HarnessConfigurationException;
import cz.moneta.test.harness.exception.HarnessException;
import cz.moneta.test.harness.support.auth.AuthSupport;
import cz.moneta.test.harness.support.auth.Credentials;
import cz.moneta.test.harness.support.auth.Key;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.extension.ExtensionContext.Namespace;
import org.junit.jupiter.api.extension.ExtensionContext.Store;
import org.junit.platform.engine.UniqueId;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.*;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class HarnessJunit5Extension implements BeforeAllCallback, BeforeEachCallback, ParameterResolver,
        TestExecutionExceptionHandler, AfterEachCallback, ExecutionCondition, LifecycleMethodExecutionExceptionHandler,
        BeforeTestExecutionCallback, AfterAllCallback {

    private static final Logger logger = LogManager.getLogger(HarnessJunit5Extension.class);

    public static final String ACTIVE_ENDPOINTS = "ACTIVE_ENDPOINTS";
    private static final Namespace ENDPOINT_NAMESPACE = Namespace.create("ENDPOINT");
    protected static final Namespace CONFIG_NAMESPACE = Namespace.create("CONFIG");
    protected static final Namespace GENERATORS_NAMESPACE = Namespace.create("GENERATORS");

    private static final String RESOLUTION_PASS = "PASS";
    private static final String RESOLUTION_FAIL = "FAIL";

    private static final String BROWSER_CONFIG_KEY = "browser";

    private boolean hasBeforeAllFail = false;
    private String beforeAllThrowableMessage;

    @Override
    public void beforeAll(ExtensionContext extensionContext) {
        extensionContext.getStore(ENDPOINT_NAMESPACE).put(ACTIVE_ENDPOINTS, new CopyOnWriteArraySet<Class<? extends Endpoint>>());
    }

    @Override
    public void beforeEach(ExtensionContext extensionContext) {
        extensionContext.getStore(ENDPOINT_NAMESPACE).put(ACTIVE_ENDPOINTS, new CopyOnWriteArraySet<Class<? extends Endpoint>>());
        extensionContext.getStore(ExtensionContext.Namespace.create("UPLOADS")).remove("jira.uploads");
    }

    @Override
    public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
        return Stream.<Supplier<Optional<?>>>of(
                () -> getTestContext(parameterContext),
                () -> getAuthKey(parameterContext))
                .map(Supplier::get)
                .anyMatch(Optional::isPresent);
    }

    @Override
    public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
        storeCurrentTestName(extensionContext);

        return Stream.<Supplier<Optional<?>>>of(
                () -> getTestContext(parameterContext).map(tc -> {
                    try {
                        return tc.getType().getConstructor(Store.class, Store.class, Store.class, Store.class, Store.class)
                                .newInstance(
                                        extensionContext.getRoot().getStore(Namespace.GLOBAL),
                                        extensionContext.getStore(Namespace.GLOBAL),
                                        extensionContext.getStore(ENDPOINT_NAMESPACE),
                                        extensionContext.getStore(CONFIG_NAMESPACE),
                                        extensionContext.getStore(GENERATORS_NAMESPACE));
                    } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException e) {
                        throw new ParameterResolutionException("Failed to initialize parameter " +
                                parameterContext.getParameter().getType().getSimpleName(), e);
                    }
                }),
                () -> getAuthKey(parameterContext)
                        .map(ak -> ak.getAnnotation(Key.class).value())
                        .map(ak -> AuthSupport.getCredentials(ak, new BaseStoreAccessor(
                                extensionContext.getRoot().getStore(Namespace.GLOBAL),
                                extensionContext.getStore(Namespace.GLOBAL),
                                extensionContext.getStore(ENDPOINT_NAMESPACE),
                                extensionContext.getStore(CONFIG_NAMESPACE),
                                extensionContext.getStore(GENERATORS_NAMESPACE)) {
                        })))
                .map(Supplier::get)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .findFirst()
                .orElseThrow(() -> new ParameterResolutionException("Parameter of type " +
                        parameterContext.getParameter().getType().getSimpleName() +
                        " is not supported"));
    }

    @Override
    public void handleTestExecutionException(ExtensionContext extensionContext, Throwable throwable) throws Throwable {
        processException(extensionContext);

        throw Optional.of(throwable)
                .filter(HarnessException.class::isInstance)
                .map(AssertionError::new)
                .map(Throwable.class::cast)
                .orElse(throwable);
    }

    @Override
    public void handleBeforeAllMethodExecutionException(ExtensionContext extensionContext, Throwable throwable) {
        //This is workaround for possible bug in JUnit5 and Surefire - in case of beforeAll exception are test skipped instead of failed
        //https://github.com/junit-team/junit5/issues/2178
        hasBeforeAllFail = true;
        beforeAllThrowableMessage = throwable.toString();

        processException(extensionContext);
        JiraTestResultPublisher.logJiraTestResult(extensionContext, throwable);
        logger.error("Method @BeforeAll in test class {} failed with exception: {}.", () -> extensionContext.getRequiredTestClass().getName(), () -> beforeAllThrowableMessage);
    }

    @Override
    public void beforeTestExecution(ExtensionContext extensionContext) {
        if (hasBeforeAllFail) {
            throw new BeforeAllHarnessException("Exception in before all occurred. Original exception message: " + beforeAllThrowableMessage);
        }
    }

    private void processException(ExtensionContext extensionContext) {
        Set<String> files = processWebEndpoints(extensionContext);
        processMobileEndpoints(extensionContext);
        processGreenscreenEndpoints(extensionContext);

        Store uploads = extensionContext.getStore(Namespace.create("UPLOADS"));
        uploads.put("jira.uploads", files);
    }

    private void processGreenscreenEndpoints(ExtensionContext extensionContext) {
        getActiveEndpoints(extensionContext).stream()
                .filter(p -> GreenScreenEndpoint.class.isAssignableFrom(p.getLeft()))
                .map(c -> extensionContext.getStore(ENDPOINT_NAMESPACE).get(c, GreenScreenEndpoint.class))
                .forEach(e -> logger.info("Failed on screen:\n" + e.getText(1, 1, 0)));
    }

    private void processMobileEndpoints(ExtensionContext extensionContext) {
        getActiveEndpoints(extensionContext).stream()
                .filter(p -> MobileEndpoint.class.isAssignableFrom(p.getLeft()))
                .map(c -> extensionContext.getStore(ENDPOINT_NAMESPACE).get(c, MobileEndpoint.class))
                .forEach(mobileEndpoint -> {
                    mobileEndpoint.takeSnapshot(extensionContext.getDisplayName());
                    mobileEndpoint.saveSources(extensionContext.getDisplayName());
                });
    }

    private Set<String> processWebEndpoints(ExtensionContext extensionContext) {
        return getActiveEndpoints(extensionContext).stream()
                .filter(p -> WebEndpoint.class.isAssignableFrom(p.getLeft()))
                .map(c -> extensionContext.getStore(ENDPOINT_NAMESPACE).get(c, WebEndpoint.class))
                .map(webEndpoint -> {
                    Set<String> files = new HashSet<>();
                    String filePrefix = (extensionContext.getTestClass().get().getSimpleName() + "_" + extensionContext.getDisplayName()).replaceAll("[\\\\/:*?\"<>|]", "");
                    files.add(webEndpoint.takeSnapshot(filePrefix));
                    files.addAll(webEndpoint.captureLogs(filePrefix));
                    webEndpoint.captureDom(filePrefix);
                    return files;
                })
                .filter(set -> !set.isEmpty())
                .collect(HashSet::new, Set::addAll, Set::addAll);
    }

    @SuppressWarnings("unchecked")
    private Set<Pair<Class<? extends Endpoint>, Object[]>> getActiveEndpoints(ExtensionContext extensionContext) {
        return extensionContext.getStore(ENDPOINT_NAMESPACE).get(ACTIVE_ENDPOINTS, Set.class);
    }

    private void closeActiveEndpoints(ExtensionContext extensionContext) {
        getActiveEndpoints(extensionContext).stream()
                .filter(Objects::nonNull)
                .map(c -> extensionContext.getStore(ENDPOINT_NAMESPACE).remove(c, Endpoint.class))
                .peek(e -> {
                    if (e instanceof MobileEndpoint) {
                        ((MobileEndpoint) e).captureVideo(extensionContext.getDisplayName());
                    }
                })
                .forEach(Endpoint::close);
    }

    @Override
    public void afterEach(ExtensionContext extensionContext) {
        closeActiveEndpoints(extensionContext);
        if (!hasBeforeAllFail) {
            JiraTestResultPublisher.logJiraTestResult(extensionContext);
        }
        logTestResult(extensionContext);
    }

    @Override
    public void afterAll(ExtensionContext extensionContext) {
        //Closes only endpoints initialized in BeforeAll and not used in test methods
        closeActiveEndpoints(extensionContext);
    }

    private Optional<Parameter> getAuthKey(ParameterContext parameterContext) {
        return Optional.of(parameterContext.getParameter())
                .filter(p -> p.getAnnotation(Key.class) != null)
                .filter(p -> Credentials.class.isAssignableFrom(p.getType()));
    }

    private Optional<Parameter> getTestContext(ParameterContext parameterContext) {
        return Optional.of(parameterContext.getParameter())
                .filter(p -> p.getType().getAnnotation(TestContext.class) != null)
                .filter(p -> StoreAccessor.class.isAssignableFrom(p.getType()));
    }

    @Override
    public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
        if (isClassExtensionContext(context)) {
            return evaluateClassExecutionContext(context);
        }

        Environment environment = getCurrentEnvironment(context);
        return context.getTestMethod()
                .filter(m -> environment != null)
                .flatMap(m -> Stream.<Supplier<Optional<ConditionEvaluationResult>>>of(
                        () -> checkTestCaseEnvironments(environment, m, context.getDisplayName()),
                        () -> checkDefectEnvironments(environment, m, context.getDisplayName(), context),
                        () -> checkGlobalParameterSet(context, m, context.getDisplayName()),
                        () -> checkJiraReportingRules(context, m, context.getDisplayName()))
                        .map(Supplier::get)
                        .filter(Optional::isPresent)
                        .map(Optional::get)
                        .findFirst())
                .orElse(enableEvaluationAndLogExecutionStart(context));
    }

    private boolean isClassExtensionContext(ExtensionContext extensionContext) {
        String simpleTestName = extensionContext.getTestClass()
                .get()
                .getSimpleName();

        return !extensionContext.getTestMethod().isPresent() && extensionContext.getDisplayName().equals(simpleTestName);
    }

    private ConditionEvaluationResult evaluateClassExecutionContext(ExtensionContext extensionContext) {
        ConfigProvider.readConfig().forEach((k, v) -> extensionContext.getStore(CONFIG_NAMESPACE).put(k, v));
        Browser definedBrowser = getBrowserFromConfig(extensionContext);
        String className = getClassCanonicalName(extensionContext);
        Class<?> testClass = extensionContext.getTestClass().get();

        return Stream.<Supplier<Optional<Browser[]>>>of(
                () -> Optional.ofNullable(testClass.getAnnotation(TestScenario.class))
                        .map(TestScenario::browsers),
                () -> Optional.ofNullable(testClass.getAnnotation(NonConcurrentTestScenario.class))
                        .map(NonConcurrentTestScenario::browsers),
                () -> Optional.ofNullable(testClass.getAnnotation(TestScenarioWithOrder.class))
                        .map(TestScenarioWithOrder::browsers))
                .map(Supplier::get)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .findFirst()
                .filter(browsers -> Arrays.stream(browsers)
                        .noneMatch(definedBrowser::equals))
                .map(browsers -> ConditionEvaluationResult.disabled("Tests in class " +
                        className + " disabled. It is marked to only run on " +
                        Arrays.stream(browsers)
                                .map(Enum::name)
                                .collect(Collectors.joining(", "))))
                .orElse(enableClassForTests(className));
    }

    private ConditionEvaluationResult enableClassForTests(String className) {
        logger.info("Test execution in test class {} started.", () -> className);
        return ConditionEvaluationResult.enabled("Tests in " + className + " enabled.");
    }

    private Optional<ConditionEvaluationResult> checkGlobalParameterSet(ExtensionContext extensionContext, Method testMethod, String testName) {
        Store rootStore = extensionContext.getRoot().getStore(Namespace.GLOBAL);
        return Optional.of(Arrays.stream(testMethod.getAnnotationsByType(EnableIfSet.class))
                .map(annotation -> {
                    if (rootStore == null) {
                        return ConditionEvaluationResult.disabled(String.format("Test %s is disabled: Need to check key '%s' " +
                                "to be set, but root context is null. Skipping...", testName, annotation.globalKey()));
                    }
                    Object value = rootStore.get(annotation.globalKey());
                    if (value == null) {
                        return ConditionEvaluationResult.disabled(String.format("Test %s is disabled: Key '%s' " +
                                "is not set in global context. Skipping...", testName, annotation.globalKey()));
                    }
                    return null;
                }).filter(Objects::nonNull)
                .findFirst()
                .orElse(ConditionEvaluationResult.enabled("Test " + testName + " enabled")));
    }

    private Optional<ConditionEvaluationResult> checkJiraReportingRules(ExtensionContext extensionContext, Method testMethod, String testName) {
        if (Boolean.parseBoolean(extensionContext.getStore(CONFIG_NAMESPACE).get("reports.tmfj.publish", String.class))) {
            return Optional.of(Arrays.stream(testMethod.getAnnotationsByType(JiraTestCase.class))
                    .map(annotation -> {
                        if (JiraTestResultPublisher.doesTestCaseExist(annotation.id(), extensionContext)) {
                            return null;
                        } else {
                            return ConditionEvaluationResult.disabled(String.format("Test %s is disabled: Cannot find Test Case with id '%s' " +
                                    "in Jira, but reporting to Jira is requested. Skipping...", testName, annotation.id()));
                        }
                    }).filter(Objects::nonNull)
                    .findFirst()
                    .orElse(ConditionEvaluationResult.enabled("Test " + testName + " enabled")));
        }
        return Optional.empty();
    }

    private Optional<ConditionEvaluationResult> checkTestCaseEnvironments(Environment environment, Method testMethod,
                                                                          String testName) {
        return Stream.<Supplier<Optional<Environment[]>>>of(
                () -> Optional.ofNullable(testMethod.getAnnotation(TestCase.class))
                        .map(TestCase::environments),
                () -> Optional.ofNullable(testMethod.getAnnotation(ParameterizedTestCase.class))
                        .map(ParameterizedTestCase::environments))
                .map(Supplier::get)
                .filter(Optional::isPresent)
                .map(Optional::get)
                .findFirst()
                .filter(envs -> Arrays.stream(envs).noneMatch(environment::equals))
                .map(envs -> ConditionEvaluationResult.disabled("Test " +
                        testName + " disabled. It is marked to only run on " +
                        Arrays.stream(envs).map(Enum::name).collect(Collectors.joining(", "))));
    }

    private Optional<ConditionEvaluationResult> checkDefectEnvironments(Environment environment, Method testMethod,
                                                                        String testName, ExtensionContext context) {
        return Optional.of(context.getStore(CONFIG_NAMESPACE)
                .getOrComputeIfAbsent("harness.defects.ignore", k -> "false"))
                .filter(ignore -> !"true".equals(ignore))
                .flatMap(i -> Arrays.stream(testMethod.getAnnotationsByType(Defect.class))
                        .filter(d -> Arrays.stream(d.environments()).anyMatch(environment::equals))
                        .findFirst())
                .map(d -> ConditionEvaluationResult.disabled("Test " +
                        testName + " in environment " + environment + " disabled due to defect '" + d.value() + "'"));
    }

    private Environment getCurrentEnvironment(ExtensionContext context) {
        return Optional.ofNullable(context.getStore(CONFIG_NAMESPACE)
                .get(HarnessConfigConstants.ENVIRONMENT_TYPE))
                .filter(String.class::isInstance)
                .map(String.class::cast)
                .map(Environment::fromString)
                .map(e -> {
                    logger.info("Detected environment: {}", () -> e.name());
                    return e;
                })
                .orElseThrow(() ->
                        new HarnessConfigurationException("You need to configure mandatory parameter " + HarnessConfigConstants.ENVIRONMENT_TYPE));
    }

    private Browser getBrowserFromConfig(ExtensionContext extensionContext) {
        String browserConfigName = System.getProperty(BROWSER_CONFIG_KEY);

        if (browserConfigName == null) {
            browserConfigName = Optional.ofNullable(extensionContext.getStore(CONFIG_NAMESPACE).get(BROWSER_CONFIG_KEY, String.class))
                    .orElseThrow(() -> new HarnessConfigurationException(("You need to configure browser to run tests!")));
        }

        Browser browser = Browser.getBrowserByConfigName(browserConfigName);
        logger.info("Detected browser: {}", browser::name);
        return browser;
    }

    private ConditionEvaluationResult enableEvaluationAndLogExecutionStart(ExtensionContext extensionContext) {
        String testName = extensionContext.getDisplayName();
        logger.info("Test case {} execution in test class {} started.", () -> testName, () -> getClassCanonicalName(extensionContext));
        return ConditionEvaluationResult.enabled("Test " + testName + " enabled");
    }

    private void logTestResult(ExtensionContext extensionContext) {
        Method testMethod = extensionContext.getRequiredTestMethod();
        Optional<Throwable> executionException = extensionContext.getExecutionException();
        String resolution = executionException.map(e -> RESOLUTION_FAIL).orElse(RESOLUTION_PASS);

        logger.info("Test case {} in test class {} executed with resolution - {}.", () -> extensionContext.getDisplayName(), () -> testMethod.getDeclaringClass(), () -> resolution);

        if (resolution.equals(RESOLUTION_FAIL)) {
            logger.error("ERROR: ", () -> executionException.get());
        }
    }

    private String getClassCanonicalName(ExtensionContext extensionContext) {
        return extensionContext.getTestClass()
                .get()
                .getCanonicalName();
    }

    private void storeCurrentTestName(ExtensionContext extensionContext) {
        String uniqueId = UniqueId.parse(extensionContext.getUniqueId())
                .getSegments()
                .stream()
                .skip(1)
                .map(UniqueId.Segment::getValue)
                .collect(Collectors.joining("."));
        extensionContext.getRoot().getStore(Namespace.GLOBAL).put(HarnessConfigConstants.TEST_UNIQUE_ID, uniqueId);

        String shortId = (extensionContext.getTestClass().get().getSimpleName() + "_" + extensionContext.getDisplayName()).replaceAll("[\\\\/:*?\"<>|]", "");
        extensionContext.getRoot().getStore(Namespace.GLOBAL).put(HarnessConfigConstants.TEST_SHORT_ID, shortId);
    }
}