/*
 * Copyright 2020 The Bazel Authors. All rights reserved.
 *
 * Licensed 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 com.google.devtools.build.android.desugar.testing.junit;

import static java.util.Collections.max;
import static java.util.Collections.min;

import com.google.common.collect.ImmutableCollection;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.flogger.GoogleLogger;
import com.google.devtools.build.android.desugar.testing.junit.SourceCompilationUnit.SourceCompilationException;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runners.BlockJUnit4ClassRunner;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.InitializationError;
import org.junit.runners.model.Statement;

/**
 * A custom test runner that powers desugar tests.
 *
 * <p>The test runner works the same way as {@link org.junit.runners.JUnit4}, except that,
 * <li>Allows each {@link Test} method to declare injectable parameters, whose values will be
 *     populated at runtime through {@link DesugarRule}.
 * <li>Performs a precondition check against {@link JdkSuppress} annotation if present. A test
 *     passes vacuously without execution, if the precondition check fails.
 */
public final class DesugarRunner extends BlockJUnit4ClassRunner {

  private static final int JAVA_RUNTIME_VERSION = JdkVersion.getJavaRuntimeVersion();

  private static final ImmutableSet<Class<? extends Annotation>> SUPPORTED_ANNOTATIONS =
      ImmutableSet.<Class<? extends Annotation>>builder()
          .addAll(RuntimeEntityResolver.SUPPORTED_QUALIFIERS)
          .add(FromParameterValueSource.class)
          .build();

  public DesugarRunner(Class<?> testClassLiteral) throws InitializationError {
    super(testClassLiteral);
  }

  @Override
  protected void validateTestMethods(List<Throwable> errors) {
    validatePublicVoidMethodsWithInjectableArgs(Test.class, /* isStatic= */ false, errors);
  }

  @Override
  protected boolean isIgnored(FrameworkMethod child) {
    return isJdkSuppressed(child) || super.isIgnored(child);
  }

  private boolean isJdkSuppressed(FrameworkMethod child) {
    JdkSuppress jdkSuppress = getJdkSuppress(child);
    return jdkSuppress != null
        && (JAVA_RUNTIME_VERSION < jdkSuppress.minJdkVersion()
            || JAVA_RUNTIME_VERSION > jdkSuppress.maxJdkVersion());
  }

  private JdkSuppress getJdkSuppress(FrameworkMethod child) {
    JdkSuppress jdkSuppress = child.getAnnotation(JdkSuppress.class);
    return jdkSuppress != null ? jdkSuppress : getTestClass().getAnnotation(JdkSuppress.class);
  }

  @Override
  protected List<FrameworkMethod> computeTestMethods() {
    List<FrameworkMethod> frameworkMethods = super.computeTestMethods();
    // Generates one method for each @ParameterValueSource instance.
    ImmutableList.Builder<FrameworkMethod> expandedFrameworkMethods = ImmutableList.builder();
    for (FrameworkMethod frameworkMethod : frameworkMethods) {
      Method reflectMethod = frameworkMethod.getMethod();
      if (reflectMethod.isAnnotationPresent(ParameterValueSourceSet.class)) {
        ParameterValueSourceSet paramValues =
            reflectMethod.getDeclaredAnnotation(ParameterValueSourceSet.class);
        for (ParameterValueSource paramValueBundle : paramValues.value()) {
          expandedFrameworkMethods.add(
              ValueSourceAnnotatedMethod.create(frameworkMethod.getMethod(), paramValueBundle));
        }
      } else if (reflectMethod.isAnnotationPresent(ParameterValueSource.class)) {
        ParameterValueSource paramValueBundle =
            reflectMethod.getDeclaredAnnotation(ParameterValueSource.class);
        expandedFrameworkMethods.add(
            ValueSourceAnnotatedMethod.create(frameworkMethod.getMethod(), paramValueBundle));
      } else {
        expandedFrameworkMethods.add(frameworkMethod);
      }
    }
    return expandedFrameworkMethods.build();
  }

  /**
   * In replacement of {@link BlockJUnit4ClassRunner#validatePublicVoidNoArgMethods} for @Test
   * method signature validation.
   */
  private void validatePublicVoidMethodsWithInjectableArgs(
      Class<? extends Annotation> annotation, boolean isStatic, List<Throwable> errors) {
    List<FrameworkMethod> methods = getTestClass().getAnnotatedMethods(annotation);
    for (FrameworkMethod eachTestMethod : methods) {
      eachTestMethod.validatePublicVoid(isStatic, errors);
      validateInjectableParameters(eachTestMethod, errors);
      validateParameterValueSource(eachTestMethod, errors);
    }
  }

  private static void validateInjectableParameters(
      FrameworkMethod eachTestMethod, List<Throwable> errors) {
    for (Parameter parameter : eachTestMethod.getMethod().getParameters()) {
      if (Arrays.stream(parameter.getDeclaredAnnotations())
          .map(Annotation::annotationType)
          .noneMatch(SUPPORTED_ANNOTATIONS::contains)) {
        errors.add(
            new Exception(
                String.format(
                    "Expected the parameter (%s) in @Test method (%s) annotated with one of"
                        + " the supported qualifiers %s to be injectable.",
                    parameter, eachTestMethod, SUPPORTED_ANNOTATIONS)));
      }
    }
  }

  private static void validateParameterValueSource(
      FrameworkMethod eachTestMethod, List<Throwable> errors) {
    Method reflectMethod = eachTestMethod.getMethod();
    long numOfParamsWithValueRequest =
        Arrays.stream(reflectMethod.getParameters())
            .filter(parameter -> parameter.isAnnotationPresent(FromParameterValueSource.class))
            .count();
    if (numOfParamsWithValueRequest > 0) {
      List<ParameterValueSource> parameterValueSources = new ArrayList<>();
      if (reflectMethod.isAnnotationPresent(ParameterValueSource.class)) {
        parameterValueSources.add(reflectMethod.getDeclaredAnnotation(ParameterValueSource.class));
      }
      if (reflectMethod.isAnnotationPresent(ParameterValueSourceSet.class)) {
        Collections.addAll(
            parameterValueSources,
            reflectMethod.getDeclaredAnnotation(ParameterValueSourceSet.class).value());
      }
      for (ParameterValueSource parameterValueSource : parameterValueSources) {
        int valueSourceLength = parameterValueSource.value().length;
        if (valueSourceLength != numOfParamsWithValueRequest) {
          errors.add(
              new Exception(
                  String.format(
                      "Parameter value source bundle (%s) and @FromParameterValueSource-annotated"
                          + " parameters in Method (%s) mismatch in length.",
                      parameterValueSource, eachTestMethod)));
        }
      }
    }
  }

  @Override
  protected Statement methodInvoker(FrameworkMethod method, Object test) {
    DesugarRule desugarRule =
        Iterables.getOnlyElement(
            getTestClass().getAnnotatedFieldValues(test, Rule.class, DesugarRule.class));

    // Compile source Java files before desugar transformation.
    try {
      desugarRule.compileSourceInputs();
    } catch (IOException | SourceCompilationException e) {
      throw new IllegalStateException(e);
    }

    JdkSuppress jdkSuppress = getJdkSuppress(method);
    if (jdkSuppress != null) {
      ImmutableMap<String, Integer> inputClassFileMajorVersions =
          desugarRule.getInputClassFileMajorVersionMap();
      ImmutableCollection<Integer> classFileVersions = inputClassFileMajorVersions.values();
      if (min(classFileVersions) < jdkSuppress.minJdkVersion()
          || max(classFileVersions) > jdkSuppress.maxJdkVersion()) {
        return new VacuousSuccess(
            String.format(
                "@Test method (%s) passed vacuously without execution: All or part of the input"
                    + " class file versions (%s) does not meet the declared prerequisite version"
                    + " range (%s) for testing.",
                method, inputClassFileMajorVersions, jdkSuppress));
      }
    }

    try {
      desugarRule.executeDesugarTransformation();
      desugarRule.injectTestInstanceFields();
      Method reflectMethod = method.getMethod();

      ImmutableMap.Builder<Parameter, Object> parameterBindingsBuilder = ImmutableMap.builder();
      // Load bindings from annotations.
      if (reflectMethod.isAnnotationPresent(ParameterValueSourceSet.class)
          || reflectMethod.isAnnotationPresent(ParameterValueSource.class)) {
        parameterBindingsBuilder.putAll(
            ((ValueSourceAnnotatedMethod) method).getResolvableParameters());
      }
      // Load bindings from desugar rule.
      parameterBindingsBuilder.putAll(desugarRule.getResolvableParameters(reflectMethod));

      ImmutableMap<Parameter, Object> parameterBindings = parameterBindingsBuilder.build();
      Parameter[] parameters = reflectMethod.getParameters();
      int parameterCount = parameters.length;
      Object[] resolvedParameterValues = new Object[parameterCount];
      for (int i = 0; i < parameterCount; i++) {
        resolvedParameterValues[i] = parameterBindings.get(parameters[i]);
      }
      return new InvokeMethodWithParams(method, test, resolvedParameterValues);
    } catch (Throwable throwable) {
      throw new IllegalStateException(throwable);
    }
  }

  /**
   * Used as in replacement of {@link org.junit.internal.runners.statements.InvokeMethod} for @Test
   * method execution.
   */
  static class InvokeMethodWithParams extends Statement {
    private final FrameworkMethod testMethod;
    private final Object target;
    private final Object[] params;

    InvokeMethodWithParams(FrameworkMethod testMethod, Object target, Object... params) {
      this.testMethod = testMethod;
      this.target = target;
      this.params = params;
    }

    @Override
    public void evaluate() throws Throwable {
      testMethod.invokeExplosively(target, params);
    }
  }

  /**
   * Used as a statement implementation indicating that a test will pass without the execution of
   * test method body.
   */
  static final class VacuousSuccess extends Statement {

    private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();

    private final String reason;

    VacuousSuccess(String reason) {
      this.reason = reason;
    }

    @Override
    public void evaluate() throws Throwable {
      logger.atWarning().log("%s", reason);
    }

    @Override
    public String toString() {
      return getClass().getSimpleName() + ":" + reason;
    }
  }
}
