/*
 * 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.cyclefinder;

import com.google.common.base.Joiner;
import com.google.common.io.Files;
import com.google.devtools.j2objc.util.ErrorUtil;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.List;
import junit.framework.TestCase;

/**
 * System tests for the CycleFinder tool.
 *
 * @author Keith Stanger
 */
public class CycleFinderTest extends TestCase {

  File tempDir;
  List<String> inputFiles;
  List<List<Edge>> cycles;
  List<String> suppressListEntries;
  List<String> restrictToListEntries;
  boolean printReferenceGraph;
  ReferenceGraph referenceGraph;

  static {
    // Prevents errors and warnings from being printed to the console.
    ErrorUtil.setTestMode();
  }

  @Override
  protected void setUp() throws IOException {
    tempDir = createTempDir();
    inputFiles = new ArrayList<>();
    suppressListEntries = new ArrayList<>();
    restrictToListEntries = new ArrayList<>();
    printReferenceGraph = false;
    referenceGraph = null;
  }

  @Override
  protected void tearDown() {
    ErrorUtil.reset();
  }

  public void testEasyCycle() throws Exception {
    addSourceFile("A.java", "class A { B b; }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertCycle("LA;", "LB;");
  }

  // TODO(nbraswell): Use com.google.j2objc.annotations.WeakOuter when transitioned to Java 8
  private static String weakOuterAndInterface = "import java.lang.annotation.*;\n"
        + "@Target(ElementType.TYPE_USE) @interface WeakOuter {}"
        + "interface Simple { public int run(); }";

  public void testAnonymousClassOuterRefCycle() throws Exception {
    addSourceFile("Simple.java", weakOuterAndInterface
        + "class Test { int member = 7; Simple o;"
        + "void f() { o = new Simple() { public int run() { return member; } }; } }");
    findCycles();

    // Assert that we have one cycle that contains LTest and a LTest anonymous class.
    assertEquals(1, cycles.size());
    assertCycle("LTest;");
    assertContains("LTest.1", printCyclesToString());
  }

  public void testAnonymousClassWithWeakOuter() throws Exception {
    addSourceFile("Simple.java", weakOuterAndInterface
        + "class Test { int member = 7; Simple o;"
        + "void f() { new @WeakOuter Simple() { public int run() { return member; } }; } }");
    findCycles();
    assertNoCycles();
  }

  public void testInnerClassWithWeakOuter() throws Exception {
    String source = "import com.google.j2objc.annotations.WeakOuter; "
        + "public class A { @WeakOuter class B { int test() { return o.hashCode(); }} B o; }";
    addSourceFile("A.java", source);
    findCycles();
    assertNoCycles();
  }

  public void testInnerClassOuterRefCycle() throws Exception {
    String source = "import com.google.j2objc.annotations.WeakOuter; "
        + "public class A { class B {int test(){return o.hashCode();}} B o;}";
    addSourceFile("A.java", source);
    findCycles();
    assertCycle("LA;", "LA.B;");
  }

  public void testWeakField() throws Exception {
    addSourceFile("A.java", "import com.google.j2objc.annotations.Weak; class A { @Weak B b; }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testRetainedWithField() throws Exception {
    addSourceFile("A.java",
        "import com.google.j2objc.annotations.RetainedWith; class A { @RetainedWith B b; }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testRecursiveTypeVariable() throws Exception {
    addSourceFile("A.java", "class A<T> { A<? extends T> a; }");
    addSourceFile("B.java", "class B<T> { B<? extends B<T>> b; }");
    addSourceFile("C.java", "class C<T> { C<java.util.List<T>> c; }");
    findCycles();
    // This test passes if it doesn't hang or crash due to infinite recursion.
  }

  public void testExtendsWildcard() throws Exception {
    addSourceFile("A.java", "class A { B<? extends C> b; }");
    addSourceFile("B.java", "class B<T> { T t; }");
    addSourceFile("C.java", "class C { A a; }");
    findCycles();
    assertCycle("LA;", "LB<+LC;>;", "+LC;");
  }

  public void testSuppressListedField() throws Exception {
    addSourceFile("A.java", "class A { B b; }");
    addSourceFile("B.java", "class B { A a; }");
    suppressListEntries.add("FIELD A.b");
    findCycles();
    assertNoCycles();
  }

  public void testSuppressListedType() throws Exception {
    addSourceFile("test/foo/A.java", "package test.foo; class A { C c; }");
    addSourceFile("test/foo/B.java", "package test.foo; class B { A a; }");
    addSourceFile("test/foo/C.java", "package test.foo; class C extends B { }");
    suppressListEntries.add("TYPE test.foo.C");
    findCycles();
    assertNoCycles();
    suppressListEntries.set(0, "TYPE test.foo.A");
    findCycles();
    assertNoCycles();
    suppressListEntries.set(0, "TYPE test.foo.B");
    findCycles();
    assertCycle("Ltest/foo/C;", "Ltest/foo/A;");
  }

  public void testSuppressListedLocalType() throws Exception {
    addSourceFile("test/foo/A.java",
      "package test.foo; class A { B b; void test() { "
      + "class Inner extends B { void foo() { A a = A.this; } } } }");
    addSourceFile("test/foo/B.java", "package test.foo; class B {}");
    suppressListEntries.add("TYPE test.foo.A.test.Inner");
    findCycles();
    assertNoCycles();
  }

  public void testSuppressListedAnonymousType() throws Exception {
    addSourceFile("test/foo/A.java",
      "package test.foo; class A { B b; B test() { "
      + "return new B() { void foo() { A a = A.this; } }; } }");
    addSourceFile("test/foo/B.java", "package test.foo; class B {}");
    suppressListEntries.add("TYPE test.foo.A.test.$");
    findCycles();
    assertNoCycles();
  }

  public void testSubtypeOfSuppressListedType() throws Exception {
    addSourceFile("test/foo/A.java", "package test.foo; class A { B b; }");
    addSourceFile("test/foo/B.java", "package test.foo; class B { A a; }");
    addSourceFile("test/foo/C.java", "package test.foo; class C extends B { }");
    suppressListEntries.add("TYPE test.foo.B");
    findCycles();
    assertNoCycles();
  }

  public void testSuppressListedPackage() throws Exception {
    addSourceFile("test/foo/A.java",
                  "package test.foo; import test.bar.B; public class A { B b; }");
    addSourceFile("test/bar/B.java",
                  "package test.bar; import test.foo.A; public class B { A a; }");
    suppressListEntries.add("NAMESPACE test.bar");
    findCycles();
    assertNoCycles();
  }

  public void testSuppressListedSubtype() throws Exception {
    addSourceFile("A.java", "class A { B b; }");
    addSourceFile("B.java", "class B {}");
    addSourceFile("C.java", "class C extends B {}");
    addSourceFile("D.java", "class D extends C { A a; }");
    suppressListEntries.add("FIELD A.b C");
    findCycles();
    assertNoCycles();
  }

  public void testSuppressListedOuterReference() throws Exception {
    addSourceFile("A.java", "class A { Inner i; class Inner { void test() { A a = A.this; } } }");
    suppressListEntries.add("OUTER A.Inner");
    findCycles();
    assertNoCycles();
  }

  public void testSuppressListComment() throws Exception {
    addSourceFile("A.java", "class A { B b; }");
    addSourceFile("B.java", "class B { A a; }");
    suppressListEntries.add("# FIELD A.b");
    findCycles();
    assertCycle("LA;", "LB;");
  }

  public void testStaticField() throws Exception {
    addSourceFile("A.java", "class A { static B b; }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testArrayField() throws Exception {
    addSourceFile("A.java", "class A { B[] b; }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertCycle("LA;", "LB;");
  }

  public void testNonStaticInnerInterface() throws Exception {
    addSourceFile("A.java", "class A { interface I {} I i;}");
    findCycles();
    assertNoCycles();
  }

  public void testCapturedVariable() throws Exception {
    addSourceFile("A.java", "class A { void test() {"
        + " final B b = new B();"
        + " A a = new A() { void test() { b.hashCode(); } }; } }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertCycle("LB;");
  }

  public void testCapturedVariableNotUsed() throws Exception {
    addSourceFile("A.java", "class A { void test() {"
        + " final B b = new B();"
        + " A a = new A() { void test() { } }; } }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testWeakCapturedVariable() throws Exception {
    addSourceFile("A.java", "import com.google.j2objc.annotations.Weak;"
        + "class A { void test() {"
        + " @Weak final B b = new B();"
        + " A a = new A() { void test() { b.hashCode(); } }; } }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testFinalVarAfterAnonymousClassNotCaptured() throws Exception {
    addSourceFile("A.java", "class A { void test() {"
        + " A a = new A() {};"
        + " final B b; } }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testOutOFScopeFinalVarNotCaptured() throws Exception {
    addSourceFile("A.java", "class A { void test() {"
        + " { final B b; }"
        + " A a = new A() {}; } }");
    addSourceFile("B.java", "class B { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testAnonymousClassAssignedToStaticField() throws Exception {
    addSourceFile("A.java",
        "class A { B b; static Runnable r = new Runnable() { public void run() {} }; }");
    addSourceFile("B.java", "class B { Runnable r; }");
    findCycles();
    assertNoCycles();
  }

  public void testAnonymousClassInStaticMethod() throws Exception {
    addSourceFile("A.java", "class A { B b; static void test() { "
        + "Runnable r = new Runnable() { public void run() {} }; } }");
    addSourceFile("B.java", "class B { Runnable r; }");
    findCycles();
    assertNoCycles();
  }

  public void testNoOuterReferenceIfNotNeeded() throws Exception {
    addSourceFile("A.java", "class A { Runnable r = new Runnable() { public void run() {} }; }");
    findCycles();
    assertNoCycles();
  }

  public void testOuterReferenceToGenericClass() throws Exception {
    // B.java is added before A.java to test that the outer edge A<B>.C -> A<B> is still added
    // despite B being visited before A. The outer reference cannot be known until A is visited.
    addSourceFile("B.java", "class B { A<B>.C abc; }");
    addSourceFile("A.java", "class A<T> { int i; T t; class C { void test() { i++; } } }");
    findCycles();
    assertCycle("LA<LB;>;", "LB;", "LA<LB;>.C;");
  }

  public void testrestrictToList() throws Exception {
    addSourceFile("A.java", "class A { B b; C c; }");
    addSourceFile("B.java", "class B { A a; }");
    addSourceFile("C.java", "class C { A a; }");
    restrictToListEntries.add("TYPE C");
    findCycles();
    assertEquals(1, cycles.size());
    assertCycle("LA;", "LC;");
  }

  public void testAnonymousLineNumbers() throws Exception {
    addSourceFile("Test.java",
        "class Test {\n"
        + " void dummy() {}\n"
        + " Runnable r = new Runnable() { public void run() { dummy(); } }; }");
    findCycles();
    assertEquals(1, cycles.size());
    assertCycle("LTest;");
    for (Edge e : cycles.get(0)) {
      assertContains("anonymous:3", e.toString());
    }
  }

  public void testSuppressListedAnonymousTypesInClassScope() throws Exception {
    addSourceFile("bar/AbstractA.java", "package bar; public class AbstractA {}");
    addSourceFile("bar/AbstractB.java", "package bar; public class AbstractB {}");
    addSourceFile("foo/Test.java",
        "package foo; import bar.AbstractA; import bar.AbstractB;"
        + " class Test { AbstractA a = new AbstractA() { void dummyA() {}"
        + " AbstractB b = new AbstractB() { void dummyB() { dummyA(); } }; }; }");
    suppressListEntries.add("NAMESPACE foo");
    findCycles();
    assertNoCycles();
  }

  public void testIncompatibleStripping() throws Exception {
    addSourceFile("Test.java",
        "import com.google.j2objc.annotations.J2ObjCIncompatible; "
        + "import java.garbage.Foo; class Test { @J2ObjCIncompatible Foo foo; }");
    // We just care that there are no compile errors.
    findCycles();
    assertNoCycles();
  }

  public void testIgnoreRawTypes() throws Exception {
    addSourceFile("A.java", "class A<T> { B b; }");
    addSourceFile("B.java", "class B<T> { A a; }");
    findCycles();
    assertNoCycles();
  }

  public void testSimpleLambdaWithCycle() throws Exception {
    addSourceFile("I.java", "interface I { int foo(); }");
    addSourceFile("A.java", "class A { int j = 1; I i = () -> j; }");
    findCycles();
    assertCycle("LA.$Lambda$1;", "LA;");
  }

  public void testMethodReferenceCycle() throws Exception {
    addSourceFile("I.java", "interface I { int foo(); }");
    addSourceFile("A.java", "class A { int bar() { return 1; } I i = this::bar; }");
    findCycles();
    assertCycle("LA.$Lambda$1;", "LA;");
  }

  public void testPrintReferenceGraph() throws Exception {
    addSourceFile("A.java", "class A { B<? extends C> b; }");
    addSourceFile("B.java", "class B<T> { T t; }");
    addSourceFile("C.java", "class C { A a; }");
    printReferenceGraph = true;
    findCycles();
    String graph = printReferenceGraphToString();
    assertContains("class: LB<+LC;>;", graph);
    assertContains("A -> (field b with type B<? extends C>)", graph);
    assertContains("C -> (field a with type A)", graph);
  }

  public void testInnerClassWithExternalWeakOuter() throws Exception {
    String externalWeakOuterAnnotation =
        "package com.google.j2objc.annotations: "
            + "annotation @WeakOuter: "
            + "@java.lang.annotation.Target(value={TYPE, LOCAL_VARIABLE})\n\n"
            + "package p: "
            + "class A$B: "
            + "  @com.google.j2objc.annotations.WeakOuter ";
    String source = "package p; "
        + "public class A { class B { int test() { return o.hashCode(); }} B o; }";
    addSourceFile("A.java", source);
    Options options = new Options();
    addExternalAnnotationFileContents(options, externalWeakOuterAnnotation);
    findCycles(options);
    assertNoCycles();
  }

  public void testFieldWithExternalWeak() throws Exception {
    String externalWeakAnnotation =
        "package com.google.j2objc.annotations: "
            + "annotation @Weak: "
            + "@java.lang.annotation.Target(value={FIELD, LOCAL_VARIABLE, PARAMETER})\n\n"
            + "package p: "
            + "class A: "
            + "field b:"
            + "  @com.google.j2objc.annotations.Weak ";
    addSourceFile("A.java", "package p; class A { B b; }");
    addSourceFile("B.java", "package p; class B { A a; }");
    Options options = new Options();
    addExternalAnnotationFileContents(options, externalWeakAnnotation);
    findCycles(options);
    assertNoCycles();
  }

  public void testFieldWithExternalRetainedWith() throws Exception {
    String externalRetainedWithAnnotation =
        "package com.google.j2objc.annotations: "
            + "annotation @RetainedWith: "
            + "@java.lang.annotation.Target(value={FIELD})\n\n"
            + "package p: "
            + "class A: "
            + "field b:"
            + "  @com.google.j2objc.annotations.RetainedWith ";
    addSourceFile("A.java", "package p; class A { B b; }");
    addSourceFile("B.java", "package p; class B { A a; }");
    Options options = new Options();
    addExternalAnnotationFileContents(options, externalRetainedWithAnnotation);
    findCycles(options);
    assertNoCycles();
  }

  public void testWeakOuterAnonymousTypeInArrayField() throws Exception {
    // Test adapted from cycle in UCharacterProperty.java.
    addSourceFile("Test.java", String.join("\n",
        "import com.google.j2objc.annotations.WeakOuter;",
        "class Test {",
        "  @WeakOuter private class IntProperty {}",
        "  IntProperty intProps[]={",
        "    new @WeakOuter IntProperty() {},",
        "  };",
        "}"));
    findCycles();
    assertNoCycles();
  }

  private void assertContains(String substr, String str) {
    assertTrue("Expected \"" + substr + "\" within \"" + str + "\"", str.contains(substr));
  }

  private void assertNoCycles() {
    assertNotNull(cycles);
    assertTrue("Expected no cycles: " + printCyclesToString(), cycles.isEmpty());
  }

  private void assertCycle(String... types) {
    assertNotNull(cycles);
    outer: for (List<Edge> cycle : cycles) {
      List<String> cycleTypes = new ArrayList<>();
      for (Edge e : cycle) {
        cycleTypes.add(e.getOrigin().getSignature());
      }
      for (String type : types) {
        if (!cycleTypes.contains(type)) {
          continue outer;
        }
      }
      return;
    }
    fail("No cycle found with types: " + Joiner.on(", ").join(types) + "\n"
         + printCyclesToString());
  }

  private String printCyclesToString() {
    ByteArrayOutputStream cyclesOut = new ByteArrayOutputStream();
    CycleFinder.printCycles(cycles, new PrintStream(cyclesOut));
    return cyclesOut.toString();
  }

  private String printReferenceGraphToString() {
    ByteArrayOutputStream referenceGraphOut = new ByteArrayOutputStream();
    referenceGraph.print(new PrintStream(referenceGraphOut));
    return referenceGraphOut.toString();
  }

  private void findCycles() throws IOException {
    findCycles(new Options());
  }

  private void findCycles(Options options) throws IOException {
    if (!suppressListEntries.isEmpty()) {
      File suppressListFile = new File(tempDir, "suppress_list");
      Files.asCharSink(suppressListFile, Charset.defaultCharset())
          .write(Joiner.on("\n").join(suppressListEntries));
      options.addSuppressListFile(suppressListFile.getAbsolutePath());
    }
    if (!restrictToListEntries.isEmpty()) {
      File restrictToListFile = new File(tempDir, "type_filter");
      Files.asCharSink(restrictToListFile, Charset.defaultCharset())
          .write(Joiner.on("\n").join(restrictToListEntries));
      options.addRestrictToFile(restrictToListFile.getAbsolutePath());
    }
    options.setSourceFiles(inputFiles);
    options.setClasspath(System.getProperty("java.class.path"));
    if (printReferenceGraph) {
      options.setPrintReferenceGraph();
    }
    CycleFinder finder = new CycleFinder(options);
    finder.constructGraph();
    cycles = finder.findCycles();
    if (printReferenceGraph) {
      referenceGraph = finder.getReferenceGraph();
    }
    if (ErrorUtil.errorCount() > 0) {
      fail("CycleFinder failed with errors:\n"
           + Joiner.on("\n").join(ErrorUtil.getErrorMessages()));
    }
  }

  private File addFile(String fileName, String source) throws IOException {
    File file = new File(tempDir, fileName);
    file.getParentFile().mkdirs();
    Files.asCharSink(file, Charset.defaultCharset()).write(source);
    return file;
  }

  private void addSourceFile(String fileName, String source) throws IOException {
    inputFiles.add(addFile(fileName, source).getAbsolutePath());
  }

  private void addExternalAnnotationFileContents(Options options, String content)
      throws IOException {
    options.addExternalAnnotationFile(addFile("annotations.jaif", content).getPath());
  }

  private File createTempDir() throws IOException {
    File tempDir = File.createTempFile("cyclefinder_testout", "");
    tempDir.delete();
    tempDir.mkdir();
    return tempDir;
  }
}
