// Copyright 2013 Google Inc. 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.j2objc.testing;

import com.google.j2objc.annotations.AutoreleasePool;
import com.google.j2objc.annotations.WeakOuter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintStream;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Random;
import java.util.Set;
import junit.framework.Test;
import junit.runner.Version;
import org.junit.internal.TextListener;
import org.junit.runner.Description;
import org.junit.runner.JUnitCore;
import org.junit.runner.Result;
import org.junit.runner.RunWith;
import org.junit.runner.notification.Failure;
import org.junit.runner.notification.RunListener;
import org.junit.runners.Suite;

/*-[
#include <objc/runtime.h>
]-*/

/**
 * Runs JUnit test classes.
 *
 * Provides a main() function that runs all JUnit tests linked into the executable.
 * The main() function accepts no arguments since Pulse unit tests are not designed to accept
 * arguments. Instead the code expects a file called "JUnitTestRunner.properties" to be include
 * as a resource.
 *
 * Any classes derived from {@link Test} (JUnit 3) or {@link Suite} (JUnit 4) are considered
 * JUnit tests. This behavior can be changed by overriding {@link #isJUnitTestClass},
 * {@link #isJUnit3TestClass} or {@link #isJUnit4TestClass}.
 *
 * @author iroth@google.com (Ian Roth)
 */
public class JUnitTestRunner {

  private static final String PROPERTIES_FILE_NAME = "JUnitTestRunner.properties";

  /**
   * Specifies the output format for tests.
   */
  public enum OutputFormat {
    JUNIT,            // JUnit style output.
    GTM_UNIT_TESTING  // Google Toolkit for Mac unit test output format.
  }

  /**
   * Specifies the sort order for tests.
   */
  public enum SortOrder {
    ALPHABETICAL,  // Sorted alphabetically
    RANDOM         // Sorted randomly (differs with each run)
  }

  /**
   * Specifies whether a pattern includes or excludes test classes.
   */
  public enum TestInclusion {
    RUN_TEST,  // Includes tests that exactly match the pattern
    INCLUDE,   // Includes test classes matching the pattern
    EXCLUDE    // Excludes test classes matching the pattern
  }

  private final PrintStream out;
  private final Set<String> testsToRun = new HashSet<>();
  private final Set<String> includePatterns = new HashSet<>();
  private final Set<String> excludePatterns = new HashSet<>();
  private final Map<String, String> nameMappings = new HashMap<>();
  private final Map<String, String> randomNames = new HashMap<>();
  private final Random random = new Random(System.currentTimeMillis());
  private OutputFormat outputFormat = OutputFormat.JUNIT;
  private SortOrder sortOrder = SortOrder.ALPHABETICAL;

  public JUnitTestRunner() {
    this(System.err);
  }

  public JUnitTestRunner(PrintStream out) {
    this.out = out;
  }

  public static int main(String[] args) {
    // Create JUnit test runner.
    PrintStream nsLogOut = new PrintStream(new NSLogOutputStream(), true);
    JUnitTestRunner runner = new JUnitTestRunner(nsLogOut);
    runner.loadPropertiesFromResource(PROPERTIES_FILE_NAME);
    return runner.run();
  }

  /**
   * Runs the test classes given in {@param classes}.
   * @returns Zero if all tests pass, non-zero otherwise.
   */
  public static int run(Class<?>[] classes, RunListener listener) {
    JUnitCore junitCore = new JUnitCore();
    junitCore.addListener(listener);
    boolean hasError = false;
    for (@AutoreleasePool Class<?> c : classes) {
      Result result = junitCore.run(c);
      hasError = hasError || !result.wasSuccessful();
    }
    return hasError ? 1 : 0;
  }

  /**
   * Runs the test classes that match settings in {@link #PROPERTIES_FILE_NAME}.
   * @returns Zero if all tests pass, non-zero otherwise.
   */
  public int run() {
    if (outputFormat == OutputFormat.GTM_UNIT_TESTING) {
      Thread.setDefaultUncaughtExceptionHandler(new GtmUncaughtExceptionHandler());
    }
    Set<Class<?>> classesSet = getTestClasses();
    Class<?>[] classes = classesSet.toArray(new Class<?>[classesSet.size()]);
    sortClasses(classes, sortOrder);
    RunListener listener = newRunListener(outputFormat);
    return run(classes, listener);
  }

  /**
   * Returns a new {@link RunListener} instance for the given {@param outputFormat}.
   */
  public RunListener newRunListener(OutputFormat outputFormat) {
    switch (outputFormat) {
      case JUNIT:
        out.println("JUnit version " + Version.id());
        return new TextListener(out);
      case GTM_UNIT_TESTING:
        return new GtmUnitTestingTextListener();
      default:
        throw new IllegalArgumentException("outputFormat");
    }
  }

  /**
   * Sorts the classes given in {@param classes} according to {@param sortOrder}.
   */
  public void sortClasses(Class<?>[] classes, final SortOrder sortOrder) {
    Arrays.sort(classes, new Comparator<Class<?>>() {
      @Override
      public int compare(Class<?> class1, Class<?> class2) {
        String name1 = getSortKey(class1, sortOrder);
        String name2 = getSortKey(class2, sortOrder);
        return name1.compareTo(name2);
      }
    });
  }

  private String replaceAll(String value) {
    for (Map.Entry<String, String> entry : nameMappings.entrySet()) {
      String pattern = entry.getKey();
      String replacement = entry.getValue();
      value = value.replaceAll(pattern, replacement);
    }
    return value;
  }

  private String getSortKey(Class<?> cls, SortOrder sortOrder) {
    String className = cls.getName();
    switch (sortOrder) {
      case ALPHABETICAL:
        return replaceAll(className);
      case RANDOM:
        String sortKey = randomNames.get(className);
        if (sortKey == null) {
          sortKey = Integer.toString(random.nextInt());
          randomNames.put(className, sortKey);
        }
        return sortKey;
      default:
        throw new IllegalArgumentException("sortOrder");
    }
  }

  /*-[
  // Returns true if |cls| conforms to the NSObject protocol.
  BOOL IsNSObjectClass(Class cls) {
    while (cls != nil) {
      if (class_conformsToProtocol(cls, @protocol(NSObject))) {
        return YES;
      }
      // class_conformsToProtocol() does not examine superclasses.
      cls = class_getSuperclass(cls);
    }
    return NO;
  }
  ]-*/

  /**
   * Returns the set of all loaded JUnit test classes.
   */
  private native Set<Class<?>> getAllTestClasses() /*-[
    int classCount = objc_getClassList(NULL, 0);
    Class *classes = (Class *)malloc(classCount * sizeof(Class));
    objc_getClassList(classes, classCount);
    id<JavaUtilSet> result = AUTORELEASE([[JavaUtilHashSet alloc] init]);
    for (int i = 0; i < classCount; i++) {
      @try {
        Class cls = classes[i];
        if (IsNSObjectClass(cls)) {
          IOSClass *javaClass = IOSClass_fromClass(cls);
          if ([self isJUnitTestClassWithIOSClass:javaClass]) {
            [result addWithId:javaClass];
          }
        }
      }
      @catch (NSException *e) {
        // Ignore any exceptions thrown by class initialization.
      }
    }
    free(classes);
    return result;
  ]-*/;

  /**
   * @return true if {@param cls} is either a JUnit 3 or JUnit 4 test.
   */
  protected boolean isJUnitTestClass(Class<?> cls) {
    return !Modifier.isAbstract(cls.getModifiers())
            && (isJUnit3TestClass(cls) || isJUnit4TestClass(cls));
  }

  /**
   * @return true if {@param cls} derives from {@link Test} and is not part of the
   * {@link junit.framework} package.
   */
  protected boolean isJUnit3TestClass(Class<?> cls) {
    if (Test.class.isAssignableFrom(cls)) {
      String packageName = getPackageName(cls);
      return !packageName.startsWith("junit.framework")
          && !packageName.startsWith("junit.extensions");
    }
    return false;
  }

  /** @return true if {@param cls} is {@link RunWith} annotated. */
  protected boolean isJUnit4TestClass(Class<?> cls) {
    return cls.getAnnotation(RunWith.class) != null;
  }

  /**
   * Returns the name of a class's package or "" for the default package
   * or (for Foundation classes) no package object.
   */
  private String getPackageName(Class<?> cls) {
    Package pkg = cls.getPackage();
    return pkg != null ? pkg.getName() : "";
  }

  /**
   * Returns the set of test classes that match settings in {@link #PROPERTIES_FILE_NAME}.
   */
  private Set<Class<?>> getTestClasses() {
    Set<Class<?>> testClasses = new HashSet<>();

    for (String testName : testsToRun) {
      try {
        testClasses.add(Class.forName(testName));
      } catch (ClassNotFoundException e) {
        throw new AssertionError(e);
      }
    }

    if (!includePatterns.isEmpty()) {
      for (Class<?> testClass : getAllTestClasses()) {
        for (String includePattern : includePatterns) {
          if (matchesPattern(testClass, includePattern)) {
            testClasses.add(testClass);
            break;
          }
        }
      }
    }

    if (testsToRun.isEmpty() && includePatterns.isEmpty()) {
      // Include all tests if no include patterns specified.
      testClasses.addAll(getAllTestClasses());
    }

    // Search included tests for tests to exclude.
    Iterator<Class<?>> testClassesIterator = testClasses.iterator();
    while (testClassesIterator.hasNext()) {
      Class<?> testClass = testClassesIterator.next();
      for (String excludePattern : excludePatterns) {
        if (matchesPattern(testClass, excludePattern)) {
          testClassesIterator.remove();
          break;
        }
      }
    }

    return testClasses;
  }

  private boolean matchesPattern(Class<?> testClass, String pattern) {
    return testClass.getCanonicalName().contains(pattern);
  }

  private void loadProperties(InputStream stream) {
    Properties properties = new Properties();
    try {
      properties.load(stream);
    } catch (IOException e) {
      onError(e);
    }
    Set<String> propertyNames = properties.stringPropertyNames();
    for (String key : propertyNames) {
      String value = properties.getProperty(key);
      try {
        if (key.equals("outputFormat")) {
          outputFormat = OutputFormat.valueOf(value);
        } else if (key.equals("sortOrder")) {
          sortOrder = SortOrder.valueOf(value);
        } else if (value.equals(TestInclusion.RUN_TEST.name())) {
          testsToRun.add(key);
        } else if (value.equals(TestInclusion.INCLUDE.name())) {
          includePatterns.add(key);
        } else if (value.equals(TestInclusion.EXCLUDE.name())) {
          excludePatterns.add(key);
        } else {
          nameMappings.put(key, value);
        }
      } catch (IllegalArgumentException e) {
        onError(e);
      }
    }
  }

  private void loadPropertiesFromResource(String resourcePath) {
    try {
      InputStream stream = ClassLoader.getSystemClassLoader().getResourceAsStream(resourcePath);
      if (stream != null) {
        loadProperties(stream);
      } else {
        throw new IOException(String.format("Resource not found: %s", resourcePath));
      }
    } catch (Exception e) {
      onError(e);
    }
  }

  private void onError(Exception e) {
    e.printStackTrace(out);
  }

  private class GtmUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler {

    @Override
    public void uncaughtException(Thread t, Throwable e) {
      out.print("Exception in thread \"" + t.getName() + "\" ");
      e.printStackTrace(out);
      out.println("** TEST FAILED **");
    }
  }

  @WeakOuter
  private class GtmUnitTestingTextListener extends RunListener {

    private int numTests = 0;
    private int numFailures = 0;
    private final int numUnexpected = 0; // Never changes, but required in output.

    private Failure testFailure;
    private double testStartTime;

    @Override
    public void testRunFinished(Result result) throws Exception {
      printf("Executed %d tests, with %d failures (%d unexpected)\n", numTests, numFailures,
          numUnexpected);
    }

    @Override
    public void testStarted(Description description) throws Exception {
      numTests++;
      testFailure = null;
      testStartTime = System.currentTimeMillis();
      printf("Test Case '-[%s]' started.\n", parseDescription(description));
    }

    @Override
    public void testFinished(Description description) throws Exception {
      double testEndTime = System.currentTimeMillis();
      double elapsedSeconds = 0.001 * (testEndTime - testStartTime);
      String statusMessage = "passed";
      if (testFailure != null) {
        statusMessage = "failed";
        out.print(testFailure.getTrace());
      }
      printf("Test Case '-[%s]' %s (%.3f seconds).\n\n", parseDescription(description),
          statusMessage, elapsedSeconds);
    }

    @Override
    public void testFailure(Failure failure) throws Exception {
      testFailure = failure;
      numFailures++;
    }

    private String parseDescription(Description description) {
      String displayName = description.getDisplayName();
      int p1 = displayName.indexOf("(");
      int p2 = displayName.indexOf(")");
      if (p1 < 0 || p2 < 0 || p2 <= p1) {
        return displayName;
      }
      String methodName = displayName.substring(0, p1);
      String className = displayName.substring(p1 + 1, p2);
      return replaceAll(className) + " " + methodName;
    }

    private void printf(String format, Object... args) {
      // Avoid using printf() or println() because they will be flushed in pieces and cause
      // interleaving with logger messages.
      out.print(String.format(format, args));
    }
  }

  /**
   * Logs test runner output using NSLog().
   */
  private static class NSLogOutputStream extends OutputStream {
    private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();

    @Override
    public void write(int b) throws IOException {
      buffer.write(b);
    }

    @Override
    public void write(byte[] b, int off, int len) throws IOException {
      buffer.write(b, off, len);
    }

    @Override
    public native void flush() /*-[
      NSString *s = [buffer_ toStringWithNSString:@"UTF-8"];
      if (s.length) {
        NSLog(@"%@", s);
      }
      [buffer_ reset];
    ]-*/;
  }
}
