/*
 * Copyright 2014 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 org.inferred.freebuilder.processor.source.testing;

import com.google.common.base.Strings;
import com.google.common.collect.Multiset;
import com.google.common.truth.Truth;

import org.junit.Assert;
import org.junit.Test;

import java.util.Objects;

import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;

/**
 * Simple builder API for a test method, suitable for use in {@link BehaviorTester}. See the
 * JavaDoc on that class for an example.
 *
 * <p>Automatically imports {@link Assert}.* and {@link Truth#assertThat}.
 *
 * <p>Does some ugly things to get meaningful file names and line numbers in the generated source.
 * If you invoke build() directly from your test method, the generated class name and method name
 * should line up with your test. Additionally, if you invoke addLine() directly from your test
 * method, without loops, the line numbers should line up with your test in Eclipse.
 * (Specifically, if you invoke addLine with line numbers that increase monotonically, and
 * do not include extra newlines in your code strings, then the test builder will be able to
 * generate a source file with those lines at the same line numbers.) Sadly, javac does not
 * appear to produce such specific line numbers when faced with a fluent API.
 *
 * <p>Note that there is no <b>incorrect</b> way to use the class; you will just find compiler
 * error messages and assertion stack traces are more useful when your test code is simple.
 */
public class TestBuilder {

  private final StringBuilder imports = new StringBuilder();
  private final StringBuilder code = new StringBuilder();
  private int lineNumber = 2;  // Our preamble takes up the first line of the source

  public TestBuilder addStaticImport(Class<?> cls, String method) {
    return addStaticImport(cls.getCanonicalName(), method);
  }

  public TestBuilder addStaticImport(String cls, String method) {
    imports
        .append("import static ")
        .append(cls)
        .append(".")
        .append(method)
        .append("; ");
    return this;
  }

  public TestBuilder addImport(Class<?> cls) {
    return addImport(cls.getCanonicalName());
  }

  public TestBuilder addImport(String cls) {
    imports
        .append("import ")
        .append(cls)
        .append("; ");
    return this;
  }

  public TestBuilder addPackageImport(String pkg) {
    imports
        .append("import ")
        .append(pkg)
        .append(".*; ");
    return this;
  }

  /**
   * Appends a formatted line of code to the source. Formatting is done by {@link String#format},
   * except that {@link Class} instances use their entity's name unadorned, rather than the usual
   * toString implementation.
   */
  public TestBuilder addLine(String fmt, Object... args) {
    StackTraceElement caller = new Exception().getStackTrace()[1];
    if (caller.getLineNumber() > lineNumber) {
      code.append(Strings.repeat("\n", caller.getLineNumber() - lineNumber));
      lineNumber = caller.getLineNumber();
    }

    Object[] substituteArgs = new Object[args.length];
    for (int i = 0; i < args.length; i++) {
      substituteArgs[i] = substitute(args[i]);
    }
    String text = String.format(fmt, substituteArgs);

    lineNumber += text.length() - text.replace("\n", "").length() + 1;
    code.append(text).append("\n");
    return this;
  }

  /**
   * Returns a {@link JavaFileObject} for the test source added to the builder.
   */
  public TestSource build() {
    StackTraceElement caller = new Exception().getStackTrace()[1];
    return new TestSource(
        rootTestClassName(caller.getClassName()),
        caller.getMethodName(),
        imports.toString(),
        code.toString());
  }

  /**
   * Creates a unique test class name. Once no longer referenced, it can subsequently be reused,
   * to keep compiler errors and stack traces cleaner.
   */
  private static String rootTestClassName(String originalClassName) {
    int periodIndex = originalClassName.lastIndexOf('.');
    if (periodIndex != -1) {
      return originalClassName.substring(0, periodIndex)
        + ".generatedcode" + originalClassName.substring(periodIndex);
    } else {
      return "com.example.test.generatedcode.Test";
    }
  }

  private static Object substitute(Object object) {
    if (object instanceof Class) {
      return ((Class<?>) object).getCanonicalName();
    } else {
      return object;
    }
  }

  public static class TestSource {

    private final String rootClassName;
    private final String methodName;
    private final String imports;
    private final String testCode;

    private TestSource(String rootClassName, String methodName, String imports, String testCode) {
      this.rootClassName = rootClassName;
      this.methodName = methodName;
      this.imports = imports;
      this.testCode = testCode;
    }

    TestFile selectName(Multiset<String> seenNames) {
      long id = seenNames.add(rootClassName, 1) + 1;
      String name = rootClassName + (id == 1 ? "" : "__" + id);
      return new TestFile(name, methodName, imports, testCode);
    }

    @Override
    public String toString() {
      return rootClassName + "." + methodName;
    }

    @Override
    public int hashCode() {
      return Objects.hash(rootClassName, methodName, imports, testCode);
    }

    @Override
    public boolean equals(Object obj) {
      if (!(obj instanceof TestSource)) {
        return false;
      }
      TestSource other = (TestSource) obj;
      return Objects.equals(rootClassName, other.rootClassName)
          && Objects.equals(methodName, other.methodName)
          && Objects.equals(imports, other.imports)
          && Objects.equals(testCode, other.testCode);
    }
  }

  /**
   * In-memory implementation of {@link javax.tools.JavaFileObject JavaFileObject} for test code.
   */
  static class TestFile extends SimpleJavaFileObject {

    private final String className;
    private final String methodName;
    private final String source;

    private TestFile(String className, String methodName, String imports, String testCode) {
      super(CompilationUnit.uriForClass(className), Kind.SOURCE);
      this.className = className;
      this.methodName = methodName;
      int period = className.lastIndexOf('.');
      this.source = "package " + className.substring(0, period) + "; "
          + "import static " + Assert.class.getName() + ".*; "
          + "import static " + Truth.class.getName() + ".assertThat; "
          + imports
          + "public class " + className.substring(period + 1) + " {"
          + "  @" + Test.class.getName()
          + "  public static void " + methodName + "() throws Exception {\n"
          + testCode
          + "\n  }\n}";
    }

    /**
     * Gets the character content of this file object.
     */
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
      return source;
    }

    public String getClassName() {
      return className;
    }

    public String getMethodName() {
      return methodName;
    }
  }
}
