package org.testng.eclipse.ui.conversion;

import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.Annotation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.IExtendedModifier;
import org.eclipse.jdt.core.dom.ITypeBinding;
import org.eclipse.jdt.core.dom.ImportDeclaration;
import org.eclipse.jdt.core.dom.MemberValuePair;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.Name;
import org.eclipse.jdt.core.dom.NormalAnnotation;
import org.eclipse.jdt.core.dom.SimpleType;
import org.eclipse.jdt.core.dom.SingleMemberAnnotation;
import org.eclipse.jdt.core.dom.SuperConstructorInvocation;
import org.eclipse.jdt.core.dom.SuperMethodInvocation;
import org.eclipse.jdt.core.dom.Type;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.testng.AssertJUnit;
import org.testng.collections.Lists;

import com.google.common.collect.Maps;
import com.google.common.collect.Sets;

import junit.framework.Assert;

/**
 * This visitor stores all the interesting things in a JUnit class:
 * - JUnit imports
 * - "extends TestCase" declaration
 * - Methods that start with test
 * - setUp and tearDown
 * - ...
 * 
 * Created on Aug 8, 2005
 * @author Cedric Beust <cedric@beust.com>
 */
public class JUnitVisitor extends Visitor {
  private List<MethodDeclaration> m_testMethods = Lists.newArrayList();
  private List<MethodDeclaration> m_disabledTestMethods = Lists.newArrayList();
  private List<MethodDeclaration> m_beforeMethods = Lists.newArrayList();
  private List<MethodDeclaration> m_afterMethods = Lists.newArrayList();
  private List<MethodDeclaration> m_beforeClasses = Lists.newArrayList();
  private List<MethodDeclaration> m_afterClasses = Lists.newArrayList();
  private MethodDeclaration m_suite = null;

  // Parent classes
  private SimpleType m_testCase = null;
  private boolean m_isTestSuite = false;

  // The JUnit imports found on this class
  private List<ImportDeclaration> m_junitImports = Lists.newArrayList();

  // Static imports for assert methods
  private Set<String> m_assertStaticImports = Sets.newHashSet();

  // List of all the methods that have @Test(expected) or @Test(timeout)
  private Map<MemberValuePair, String> m_testsWithExpected = Maps.newHashMap();

  // The position and length of all the Assert references that are not statically
  // imported
  private Set<MethodInvocation> m_asserts = Sets.newHashSet();

  // The position and length of all the fail() calls
  private Set<MethodInvocation> m_fails = Sets.newHashSet();

  // True if there are test methods (if they are annotated with @Test, they won't
  // show up in m_testMethods).
  private boolean m_hasTestMethods = false;

  // Nodes that need to be removed by the refactoring
  private List<ASTNode> m_nodesToRemove = Lists.newArrayList();
  private SuperConstructorInvocation m_superConstructorInvocation;
  private String m_className;
  private SingleMemberAnnotation m_runWithParameterized;
  private MethodDeclaration m_parametersMethod;
  private TypeDeclaration m_type;
  private boolean m_hasDefaultConstructor = false;
  private Map<MethodDeclaration, Annotation> m_ignoredMethods = Maps.newHashMap();

  // The list of methods that are present on JUnit's Assert class
  private static Set<String> m_assertMethods = Sets.newHashSet();

  static {
    for (Method m : Assert.class.getDeclaredMethods()) {
      m_assertMethods.add(m.getName());
    }
    // Also add a few methods from the JUnit4 Assert class
    m_assertMethods.add("assertArrayEquals");
  }

  @Override
  public boolean visit(ImportDeclaration id) {
    Name simpleName = id.getName();
    String name = simpleName.getFullyQualifiedName();
    if (name.indexOf("junit") != -1) {
      int ind = simpleName.toString().indexOf("assert");
      if (id.isStatic() && ind > 0) {
        m_assertStaticImports.add(simpleName.toString().substring(ind));
      }
      m_junitImports.add(id);
    }
    return super.visit(id);
  }

  @Override
  public boolean visit(SuperMethodInvocation smi) {
    String name = smi.getName().toString();
    // Only remove the call to super if the class extends TestCase directly
    if (m_testCase != null && ("setUp".equals(name) || "tearDown".equals(name))) {
      m_nodesToRemove.add(smi.getParent());
    }
    return super.visit(smi);
  }

  /**
   * Remember if we find a constructor that calls super(String).
   */
  @Override
  public boolean visit(SuperConstructorInvocation sci) {
    // Only remove the call to super if the class extends TestCase directly
    if (m_testCase != null) {
      List args = sci.arguments();
      if (args.size() == 1) {
        Expression arg = (Expression) args.get(0);
        ITypeBinding binding = arg.resolveTypeBinding();
        if (binding != null && String.class.getName().equals(binding.getBinaryName())) {
          m_superConstructorInvocation = sci;
        }
      }
    }

    return super.visit(sci);
  }

  public SuperConstructorInvocation getSuperConstructorInvocation() {
    return m_superConstructorInvocation;
  }

  @Override
  public boolean visit(MethodDeclaration md) {
    String methodName = md.getName().getFullyQualifiedName();

    if (methodName.equals("setUp") || hasAnnotation(md, "Before")) {
      m_beforeMethods.add(md);
    } else if (methodName.equals("tearDown") || hasAnnotation(md, "After")) {
      m_afterMethods.add(md);
    } else if (methodName.equals("suite")) {
      m_suite = md;
    } else if (methodName.equals(m_type.getName().toString())) {
      // A constructor
      if (md.parameters().size() == 0) {
        m_hasDefaultConstructor = true;
      }
    } else if (hasAnnotation(md, "Parameters")) {
      m_parametersMethod = md;
    } else if (hasAnnotation(md, "BeforeClass")) {
      m_beforeClasses.add(md);
    } else if (hasAnnotation(md, "AfterClass")) {
      m_afterClasses.add(md);
    } else if (hasAnnotation(md, "Ignore")) {
      m_ignoredMethods.put(md, getAnnotation(md, "Ignore"));
    } else if (! hasAnnotation(md, "Test")) {
      // Public methods that start with "test" are tests.
      // Methods that start with "_test" or private test methods that start with "test" are disabled
      boolean isPrivate = (md.getModifiers() & Modifier.PRIVATE) != 0;
      if (methodName.startsWith("test") && ! isPrivate) {
        m_testMethods.add(md);
      } else if (methodName.startsWith("_test") || (methodName.startsWith("test") && isPrivate)) {
        m_disabledTestMethods.add(md);
      }
    }  else if (hasAnnotation(md, "Test")) {
      m_hasTestMethods = true;  // to make sure we import org.testng.annotations.Test
      MemberValuePair mvp = getAttribute(md, "expected");
      if (mvp != null) {
        m_testsWithExpected.put(mvp, "expectedExceptions");
      }
      mvp = getAttribute(md, "timeout");
      if (mvp != null) {
        m_testsWithExpected.put(mvp, "timeOut");
      }
    }
    
    return super.visit(md);
  }

  /**
   * @return true if the given method is annotated @Test(expected = ...)
   */
  private MemberValuePair getAttribute(MethodDeclaration md, String attribute) {
    @SuppressWarnings("unchecked")
    List<IExtendedModifier> modifiers = md.modifiers();
    for (IExtendedModifier m : modifiers) {
      if (m.isAnnotation()) {
        Annotation a = (Annotation) m;
        if ("Test".equals(a.getTypeName().toString()) && a instanceof NormalAnnotation) {
          NormalAnnotation na = (NormalAnnotation) a;
          for (Object o : na.values()) {
            MemberValuePair mvp = (MemberValuePair) o;
            if (mvp.getName().toString().equals(attribute)) return mvp;
          }
        }
      }
    }

    return null;
  }

  /**
   * Record whether this type declaration is a TestCase or a TestSuite.
   */
  @Override
  public boolean visit(TypeDeclaration td) {
    m_className = td.getName().toString();
    m_type = td;

    //
    // Is this class annotated with @RunWith(Parameterized.class)?
    //
    List<IExtendedModifier> modifiers = td.modifiers();
    for (IExtendedModifier m : modifiers) {
      if (m.isAnnotation() && m instanceof SingleMemberAnnotation) {
        SingleMemberAnnotation a = (SingleMemberAnnotation) m;
        if ("RunWith".equals(a.getTypeName().toString()) &&
            "Parameterized.class".equals(a.getValue().toString())) {
          m_runWithParameterized = a;
        }
      }
    }

    //
    // Is the class a direct subclass of TestCase?
    //
    Type superClass = td.getSuperclassType();
    if (superClass instanceof SimpleType) {
      SimpleType st = (SimpleType) superClass;
      if ("TestCase".equals(st.getName().getFullyQualifiedName())) {
        m_testCase = st;
      }
    }

    // Is the class a subclass of TestSuite?
    if (superClass != null) {
      ITypeBinding binding = superClass.resolveBinding();
      while (binding != null) {
        if ("TestSuite".equals(binding.getName())) {
          m_isTestSuite = true;
          break;
        } else {
          binding = binding.getSuperclass();
        }
      }
    }

    return super.visit(td);
  }

  public SingleMemberAnnotation getRunWithParameterized() {
    return m_runWithParameterized;
  }

  public MethodDeclaration getParametersMethod() {
    return m_parametersMethod;
  }

  /**
   * Find occurrences of "Assert.xxx", which need to be replaced with "AssertJUnit.xxx".
   */
  @Override
  public boolean visit(MethodInvocation node) {
    Expression exp = node.getExpression();
    String method = node.getName().toString();

    if ((exp != null && "Assert".equals(exp.toString())) || method.startsWith("assert")) {
      // Method prefixed with "Assert."
      if (belongsToAssertJUnit(node) &&
          ! m_assertStaticImports.contains(node.getName().toString())) {
        m_asserts.add(node);
      }
    } else if ("fail".equals(method)) {
      // assert or fail not prefixed with "Assert."
      if (belongsToAssertJUnit(node)) m_fails.add(node);
    }
    return super.visit(node);
  }

  // Class internal names, according to
  // http://download.oracle.com/javase/1.4.2/docs/api/java/lang/Class.html#getName()
  private static Map<String, Class> BINARY_CLASS_NAMES = new HashMap<String, Class>() {{
    put("B", byte.class);
    put("C", char.class);
    put("D", double.class);
    put("F", float.class);
    put("I", int.class);
    put("J", long.class);
    put("L", Class.class);
    put("S", short.class);
    put("Z", boolean.class);
    put("[", Object[].class);
  }};

  private Class getBinaryClassName(String binaryName) {
    return BINARY_CLASS_NAMES.get(binaryName);
  }
  /**
   * @return true if this method is defined on the AssertJUnit class.
   */
  private boolean belongsToAssertJUnit(MethodInvocation method) {
    if (! m_assertMethods.contains(method.getName().toString())) return false;

    List<Expression> arguments = method.arguments();
    List<Class> types = Lists.newArrayList();
    for (Expression e : arguments) {
      ITypeBinding binding = e.resolveTypeBinding();
      // Early abort if a binding fails
      if (binding == null) {
        return true;
      }
      Class c = bindingToClass(binding);

      types.add(c);
    }
    boolean result = false;

    adjustForOverloading(types);

    // We need to correct types[1] and types[2] so they match here, in order to
    // emulate type conversions (for example, (int, long) should become (long, long)

    // Try to find a method with the exact signature. This can fail for a few reasons
    // (such as not being able to resolve the binary name), in which case I should try
    // to fall back on a simple name search
    try {
      Object m = AssertJUnit.class.getMethod(method.getName().getFullyQualifiedName(),
          types.toArray(new Class[types.size()]));
      result = true;
    } catch (SecurityException e1) {
//      e1.printStackTrace();
    } catch (NoSuchMethodException e1) {
//      e1.printStackTrace();
    }

    if (! result && (arguments.size() == 2 || arguments.size() == 3)) {
      // An assert with two or three parameters will match assertTrue(Object, Object)
      result = true;
    }
    return result;
  }

  /**
   * Modify the list of parameter types to try to match how the compiler will
   * resolve the type conversion. For example, an invocation of assert(..., int, long)
   * will probably end up calling assert(..., long, long).
   */
  private void adjustForOverloading(List<Class> types) {
    if (types.size() > 2) {
      Class t2 = types.get(1);
      Class t3 = types.get(2);
      if ((t2 == long.class && t3 == int.class) || (t3 == long.class && t2 == int.class)) {
        types.set(1, long.class);
        types.set(2, long.class);
      }
    }
  }

  /**
   * Use heuristics to try to find the right class for this binding.
   */
  private Class bindingToClass(ITypeBinding binding) {
    Class result = getBinaryClassName(binding.getBinaryName());
    if (result == null) {
      try {
        result = Class.forName(binding.getQualifiedName());
      } catch (ClassNotFoundException e) {
        // ignore
      }
    }

    if (result == null) result = Object.class;
    return result;
  }

  public Set<MethodInvocation> getAsserts() {
    return m_asserts;
  }

  private static void ppp(String s) {
    System.out.println("[JUnitVisitor] " + s);
    Assert.assertTrue(true);
  }

  public Collection<MethodDeclaration> getBeforeMethods() {
    return m_beforeMethods;
  }

  public Collection<MethodDeclaration> getAfterMethods() {
    return m_afterMethods;
  }

  public Collection<MethodDeclaration> getBeforeClasses() {
    return m_beforeClasses;
  }

  public Collection<MethodDeclaration> getAfterClasses() {
    return m_afterClasses;
  }

  public MethodDeclaration getSuite() {
    return m_suite;
  }

  public void setSuite(MethodDeclaration suite) {
    m_suite = suite;
  }

  public Collection<MethodDeclaration> getTestMethods() {
    return m_testMethods;
  }

  public Collection<MethodDeclaration> getDisabledTestMethods() {
    return m_disabledTestMethods;
  }

  public boolean hasTestMethods() {
    return m_hasTestMethods || m_testMethods.size() > 0 || m_disabledTestMethods.size() > 0;
  }

  public void setTestMethods(List<MethodDeclaration> testMethods) {
    m_testMethods = testMethods;
  }

  public SimpleType getTestCase() {
    return m_testCase;
  }

  public List<ImportDeclaration> getJUnitImports() {
    return m_junitImports;
  }

  public Set<String> getStaticImports() {
    return m_assertStaticImports;
  }

  public boolean hasAsserts() {
    return m_asserts.size() > 0;
  }

  public Set<MethodInvocation> getFails() {
    return m_fails;
  }

  public boolean hasFail() {
    return m_fails.size() > 0;
  }

  /**
   * All the @Test annotated methods that have attributes that need to be replaced.
   */
  public Map<MemberValuePair, String> getTestsWithExpected() {
    return m_testsWithExpected;
  }

  @Override
  public String toString() {
    return "[JUnitVisitor for class " + m_className + "]";
  }

  public List<ASTNode> getNodesToRemove() {
    return m_nodesToRemove;
  }

  /**
   * @return whether this file needs to be changed in order to be converted to TestNG.
   */
  public boolean needsConversion() {
    if (m_isTestSuite) {
      return false;
    }

    if (m_hasTestMethods || getTestMethods().size() > 0 || getDisabledTestMethods().size() > 0 ||
        getBeforeMethods().size() > 0 || getAfterMethods().size() > 0 || m_suite != null) {
      return true;
    }

    return false;
  }

  public TypeDeclaration getType() {
    return m_type;
  }

  public boolean hasDefaultConstructor() {
    return m_hasDefaultConstructor;
  }

  public Map<MethodDeclaration, Annotation> getIgnoredMethods() {
    return m_ignoredMethods;
  }
}
