/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.annotationProcessors;

/**
 * Generate C++ bindings for SDK classes using a config file.
 *
 * <p>java SDKProcessor <sdkjar> <max-sdk-version> <outdir> [<configfile> <fileprefix>]+
 *
 * <p><sdkjar>: jar file containing the SDK classes (e.g. android.jar) <max-sdk-version>: SDK
 * version for generated class members (bindings will not be generated for members with SDK versions
 * higher than max-sdk-version) <outdir>: output directory for generated binding files <configfile>:
 * config file for generating bindings <fileprefix>: prefix used for generated binding files
 *
 * <p>Each config file is a text file following the .ini format:
 *
 * <p>; comment [section1] property = value
 *
 * <p># comment [section2] property = value
 *
 * <p>Each section specifies a qualified SDK class. Each property specifies a member of that class.
 * The class and/or the property may specify options found in the WrapForJNI annotation. For
 * example,
 *
 * <p># Generate bindings for Bundle using default options: [android.os.Bundle]
 *
 * <p># Generate bindings for Bundle using class options: [android.os.Bundle =
 * exceptionMode:nsresult]
 *
 * <p># Generate bindings for Bundle using method options: [android.os.Bundle] putInt =
 * stubName:PutInteger
 *
 * <p># Generate bindings for Bundle using class options with method override: # (note that all
 * options are overriden at the same time.) [android.os.Bundle = exceptionMode:nsresult] # putInt
 * will have stubName "PutInteger", and exceptionMode of "abort" putInt = stubName:PutInteger #
 * putChar will have stubName "PutCharacter", and exceptionMode of "nsresult" putChar =
 * stubName:PutCharacter, exceptionMode:nsresult
 *
 * <p># Overloded methods can be specified using its signature [android.os.Bundle] # Skip the copy
 * constructor <init>(Landroid/os/Bundle;)V = skip:true
 *
 * <p># Generic member types can be specified [android.view.KeyEvent = skip:true] # Skip everything
 * except fields <field> = skip:false
 *
 * <p># Skip everything except putInt and putChar [android.os.Bundle = skip:true] putInt =
 * skip:false putChar =
 *
 * <p># Avoid conflicts in native bindings [android.os.Bundle] # Bundle(PersistableBundle) native
 * binding can conflict with Bundle(ClassLoader) <init>(Landroid/os/PersistableBundle;)V =
 * stubName:NewFromPersistableBundle
 *
 * <p># Generate a getter instead of a literal for certain runtime constants
 * [android.os.Build$VERSION = skip:true] SDK_INT = noLiteral:true
 */
import com.android.tools.lint.LintCliClient;
import com.android.tools.lint.checks.ApiLookup;
import com.android.tools.lint.client.api.LintClient;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.IOException;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Member;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Locale;
import org.mozilla.gecko.annotationProcessors.classloader.AnnotatableEntity;
import org.mozilla.gecko.annotationProcessors.classloader.ClassWithOptions;
import org.mozilla.gecko.annotationProcessors.utils.Utils;

/** Processor for generating SDK bindings from JAR files. */
public class SDKProcessor {
  /** Comment header added to all generated files. */
  public static final String GENERATED_COMMENT =
      "// GENERATED CODE\n"
          + "// Generated by the Java program at /build/annotationProcessors at compile time\n"
          + "// from annotations on Java methods. To update, change the annotations on the\n"
          + "// corresponding Javamethods and rerun the build. Manually updating this file\n"
          + "// will cause your build to fail.\n"
          + "\n";

  private static ApiLookup sApiLookup;
  private static int sMaxSdkVersion;

  private static class ParseException extends Exception {
    public ParseException(final String message) {
      super(message);
    }
  }

  private static class ClassInfo {
    public final String name;

    // Map constructor/field/method signature to a set of annotation values.
    private final HashMap<String, String> mAnnotations = new HashMap<>();
    // Default set of annotation values to use.
    private final String mDefaultAnnotation;
    // List of nested classes to forward declare.
    private final ArrayList<Class<?>> mNestedClasses;

    public ClassInfo(final String text) {
      final String[] mapping = text.split("=", 2);
      name = mapping[0].trim();
      mDefaultAnnotation = mapping.length > 1 ? mapping[1].trim() : null;
      mNestedClasses = new ArrayList<>();
    }

    public void addAnnotation(final String text) throws ParseException {
      final String[] mapping = text.split("=", 2);
      final String prop = mapping[0].trim();
      if (prop.isEmpty()) {
        throw new ParseException("Missing member name: " + text);
      }
      if (mapping.length < 2) {
        throw new ParseException("Missing equal sign: " + text);
      }
      if (mAnnotations.get(prop) != null) {
        throw new ParseException("Already has member: " + prop);
      }
      mAnnotations.put(prop, mapping[1].trim());
    }

    public AnnotationInfo getAnnotationInfo(final Member member) throws ParseException {
      String stubName = Utils.getNativeName(member);
      AnnotationInfo.ExceptionMode mode = AnnotationInfo.ExceptionMode.ABORT;
      AnnotationInfo.CallingThread thread = AnnotationInfo.CallingThread.ANY;
      AnnotationInfo.DispatchTarget target = AnnotationInfo.DispatchTarget.CURRENT;
      boolean noLiteral = false;
      boolean isGeneric = false;

      final String name = Utils.getMemberName(member);
      String annotation =
          mAnnotations.get(
              name + (member instanceof Field ? ":" : "") + Utils.getSignature(member));
      if (annotation == null) {
        // Match name without signature
        annotation = mAnnotations.get(name);
      }
      if (annotation == null) {
        // Match <constructor>, <field>, <method>
        annotation =
            mAnnotations.get(
                "<" + member.getClass().getSimpleName().toLowerCase(Locale.ROOT) + '>');
        isGeneric = true;
      }
      if (annotation == null) {
        // Fallback on class options, if any.
        annotation = mDefaultAnnotation;
      }
      if (annotation == null || annotation.isEmpty()) {
        return new AnnotationInfo(stubName, mode, thread, target, noLiteral);
      }

      final String[] elements = annotation.split(",");
      for (final String element : elements) {
        final String[] pair = element.split(":", 2);
        if (pair.length < 2) {
          throw new ParseException("Missing option value: " + element);
        }
        final String pairName = pair[0].trim();
        final String pairValue = pair[1].trim();
        switch (pairName) {
          case "skip":
            if (Boolean.valueOf(pairValue)) {
              // Return null to signal skipping current method.
              return null;
            }
            break;
          case "stubName":
            if (isGeneric) {
              // Prevent specifying stubName for class options.
              throw new ParseException("stubName doesn't make sense here: " + pairValue);
            }
            stubName = pairValue;
            break;
          case "exceptionMode":
            mode = Utils.getEnumValue(AnnotationInfo.ExceptionMode.class, pairValue);
            break;
          case "calledFrom":
            thread = Utils.getEnumValue(AnnotationInfo.CallingThread.class, pairValue);
            break;
          case "dispatchTo":
            target = Utils.getEnumValue(AnnotationInfo.DispatchTarget.class, pairValue);
            break;
          case "noLiteral":
            noLiteral = Boolean.valueOf(pairValue);
            break;
          default:
            throw new ParseException("Unknown option: " + pairName);
        }
      }
      return new AnnotationInfo(stubName, mode, thread, target, noLiteral);
    }
  }

  /**
   * Main entry point for the SDK processor.
   *
   * @param args Command line arguments: SDK jar, max version, output dir, and config/prefix pairs
   * @throws Exception if processing fails
   */
  public static void main(String[] args) throws Exception {
    // We expect a list of jars on the commandline. If missing, whinge about it.
    if (args.length < 3 || args.length % 2 != 1) {
      System.err.println(
          "Usage: java SDKProcessor sdkjar max-sdk-version outdir [configfile fileprefix]*");
      System.exit(1);
    }

    System.out.println("Processing platform bindings...");

    final File sdkJar = new File(args[0]);
    sMaxSdkVersion = Integer.parseInt(args[1]);
    final String outdir = args[2];

    final LintCliClient lintClient = new LintCliClient(LintClient.CLIENT_CLI);
    sApiLookup = ApiLookup.get(lintClient);

    for (int argIndex = 3; argIndex < args.length; argIndex += 2) {
      final String configFile = args[argIndex];
      final String generatedFilePrefix = args[argIndex + 1];
      System.out.println("Processing bindings from " + configFile);

      // Start the clock!
      long s = System.currentTimeMillis();

      // Get an iterator over the classes in the jar files given...
      // Iterator<ClassWithOptions> jarClassIterator =
      // IterableJarLoadingURLClassLoader.getIteratorOverJars(args);

      StringBuilder headerFile = new StringBuilder(GENERATED_COMMENT);
      headerFile.append(
          "#ifndef "
              + generatedFilePrefix
              + "_h__\n"
              + "#define "
              + generatedFilePrefix
              + "_h__\n"
              + "\n"
              + "#include \"mozilla/jni/Refs.h\"\n"
              + "\n"
              + "namespace mozilla {\n"
              + "namespace java {\n"
              + "namespace sdk {\n"
              + "\n");

      StringBuilder implementationFile = new StringBuilder(GENERATED_COMMENT);
      implementationFile.append(
          "#include \""
              + generatedFilePrefix
              + ".h\"\n"
              + "#include \"mozilla/jni/Accessors.h\"\n"
              + "\n"
              + "namespace mozilla {\n"
              + "namespace java {\n"
              + "namespace sdk {\n"
              + "\n");

      // Used to track the calls to the various class-specific initialisation functions.
      ClassLoader loader = null;
      try {
        loader =
            URLClassLoader.newInstance(
                new URL[] {sdkJar.toURI().toURL()}, SDKProcessor.class.getClassLoader());
      } catch (Exception e) {
        throw new RuntimeException(e.toString());
      }

      try {
        ClassInfo[] classes = getClassList(configFile);
        classes = addNestedClassForwardDeclarations(classes, loader);

        for (final ClassInfo cls : classes) {
          System.out.println("Looking up: " + cls.name);
          generateClass(Class.forName(cls.name, true, loader), cls, implementationFile, headerFile);
        }
      } catch (final IllegalStateException | IOException | ParseException e) {
        System.err.println("***");
        System.err.println("*** Error parsing config file: " + configFile);
        System.err.println("*** " + e);
        System.err.println("***");
        if (e.getCause() != null) {
          e.getCause().printStackTrace(System.err);
        }
        System.exit(1);
        return;
      }

      implementationFile.append("} /* sdk */\n" + "} /* java */\n" + "} /* mozilla */\n");

      headerFile.append("} /* sdk */\n" + "} /* java */\n" + "} /* mozilla */\n" + "#endif\n");

      writeOutputFiles(outdir, generatedFilePrefix, headerFile, implementationFile);
      long e = System.currentTimeMillis();
      System.out.println("SDK processing complete in " + (e - s) + "ms");
    }
  }

  private static int getAPIVersion(Class<?> cls, Member m) {
    if (m instanceof Method || m instanceof Constructor) {
      return sApiLookup.getMethodVersion(
          cls.getName().replace('.', '/'), Utils.getMemberName(m), Utils.getSignature(m));
    } else if (m instanceof Field) {
      return sApiLookup.getFieldVersion(
          Utils.getClassDescriptor(m.getDeclaringClass()), m.getName());
    } else {
      throw new IllegalArgumentException("expected member to be Method, Constructor, or Field");
    }
  }

  private static Member[] sortAndFilterMembers(Class<?> cls, Member[] members) {
    Arrays.sort(
        members,
        new Comparator<Member>() {
          @Override
          public int compare(Member a, Member b) {
            int result = a.getName().compareTo(b.getName());
            if (result == 0) {
              if (a instanceof Constructor && b instanceof Constructor) {
                String sa = Arrays.toString(((Constructor) a).getParameterTypes());
                String sb = Arrays.toString(((Constructor) b).getParameterTypes());
                result = sa.compareTo(sb);
              } else if (a instanceof Method && b instanceof Method) {
                String sa = Arrays.toString(((Method) a).getParameterTypes());
                String sb = Arrays.toString(((Method) b).getParameterTypes());
                result = sa.compareTo(sb);
              }
            }
            return result;
          }
        });

    ArrayList<Member> list = new ArrayList<>();
    for (final Member m : members) {
      if (m.getDeclaringClass() == Object.class) {
        // Skip methods from Object.
        continue;
      }

      // Sometimes (e.g. Bundle) has methods that moved to/from a superclass in a later SDK
      // version, so we check for both classes and see if we can find a minimum SDK version.
      int version = getAPIVersion(cls, m);
      final int version2 = getAPIVersion(m.getDeclaringClass(), m);
      if (version2 > 0 && version2 < version) {
        version = version2;
      }
      if (version > sMaxSdkVersion) {
        System.out.println(
            "Skipping "
                + m.getDeclaringClass().getName()
                + "."
                + Utils.getMemberName(m)
                + ", version "
                + version
                + " > "
                + sMaxSdkVersion);
        continue;
      }

      // Sometimes (e.g. KeyEvent) a field can appear in both a class and a superclass. In
      // that case we want to filter out the version that appears in the superclass, or
      // we'll have bindings with duplicate names.
      try {
        if (m instanceof Field && !m.equals(cls.getField(m.getName()))) {
          // m is a field in a superclass that has been hidden by
          // a field with the same name in a subclass.
          System.out.println(
              "Skipping " + Utils.getMemberName(m) + " from " + m.getDeclaringClass().getName());
          continue;
        }
      } catch (final NoSuchFieldException e) {
      }

      list.add(m);
    }

    return list.toArray(new Member[list.size()]);
  }

  private static void generateMembers(CodeGenerator generator, ClassInfo clsInfo, Member[] members)
      throws ParseException {
    for (Member m : members) {
      if (!Modifier.isPublic(m.getModifiers())) {
        continue;
      }

      // Default for SDK bindings.
      final AnnotationInfo info = clsInfo.getAnnotationInfo(m);
      if (info == null) {
        // Skip this member.
        continue;
      }
      final AnnotatableEntity entity = new AnnotatableEntity(m, info);

      if (m instanceof Constructor) {
        generator.generateConstructor(entity);
      } else if (m instanceof Method) {
        generator.generateMethod(entity);
      } else if (m instanceof Field) {
        generator.generateField(entity);
      } else {
        throw new IllegalArgumentException("expected member to be Constructor, Method, or Field");
      }
    }
  }

  private static String getGeneratedName(Class<?> clazz) {
    ArrayList<String> classes = new ArrayList<>();
    do {
      classes.add(clazz.getSimpleName());
      clazz = clazz.getDeclaringClass();
    } while (clazz != null);
    Collections.reverse(classes);
    return String.join("::", classes);
  }

  private static void generateClass(
      Class<?> clazz, ClassInfo clsInfo, StringBuilder implementationFile, StringBuilder headerFile)
      throws ParseException {
    String generatedName = getGeneratedName(clazz);

    CodeGenerator generator =
        new CodeGenerator(new ClassWithOptions(clazz, generatedName, /* ifdef */ ""));

    // Forward declaration for nested classes
    ClassWithOptions[] nestedClasses =
        clsInfo.mNestedClasses.stream()
            .map(
                nestedClass ->
                    new ClassWithOptions(nestedClass, getGeneratedName(nestedClass), null))
            .sorted((a, b) -> a.generatedName.compareTo(b.generatedName))
            .toArray(ClassWithOptions[]::new);
    generator.generateClasses(nestedClasses);

    generateMembers(generator, clsInfo, sortAndFilterMembers(clazz, clazz.getConstructors()));
    generateMembers(generator, clsInfo, sortAndFilterMembers(clazz, clazz.getMethods()));
    generateMembers(generator, clsInfo, sortAndFilterMembers(clazz, clazz.getFields()));

    headerFile.append(generator.getHeaderFileContents());
    implementationFile.append(generator.getWrapperFileContents());
  }

  private static ClassInfo[] getClassList(BufferedReader reader)
      throws ParseException, IOException {
    final ArrayList<ClassInfo> classes = new ArrayList<>();
    ClassInfo currentClass = null;
    String line;

    while ((line = reader.readLine()) != null) {
      line = line.trim();
      if (line.isEmpty()) {
        continue;
      }
      switch (line.charAt(0)) {
        case ';':
        case '#':
          // Comment
          continue;
        case '[':
          // New section
          if (line.charAt(line.length() - 1) != ']') {
            throw new ParseException("Missing trailing ']': " + line);
          }
          currentClass = new ClassInfo(line.substring(1, line.length() - 1));
          classes.add(currentClass);
          break;
        default:
          // New mapping
          if (currentClass == null) {
            throw new ParseException("Missing class: " + line);
          }
          currentClass.addAnnotation(line);
          break;
      }
    }
    if (classes.isEmpty()) {
      throw new ParseException("No class found in config file");
    }
    return classes.toArray(new ClassInfo[classes.size()]);
  }

  private static ClassInfo[] getClassList(final String path) throws ParseException, IOException {
    BufferedReader reader = null;
    try {
      reader = new BufferedReader(new FileReader(path));
      return getClassList(reader);
    } finally {
      if (reader != null) {
        reader.close();
      }
    }
  }

  /**
   * For each nested class we wish to generate bindings for, this ensures that the generated binding
   * for its outer class (recursively, until we reach a top-level class) will contain a forward
   * declaration of the nested class.
   */
  private static ClassInfo[] addNestedClassForwardDeclarations(
      final ClassInfo[] classes, final ClassLoader loader) throws ClassNotFoundException {
    final HashMap<String, ClassInfo> classMap = new HashMap<>();
    for (final ClassInfo cls : classes) {
      classMap.put(cls.name, cls);
    }

    for (final ClassInfo classInfo : classes) {
      Class<?> innerClass = Class.forName(classInfo.name, true, loader);
      while (innerClass.getDeclaringClass() != null) {
        Class<?> outerClass = innerClass.getDeclaringClass();
        ClassInfo outerClassInfo = classMap.get(outerClass.getName());
        if (outerClassInfo == null) {
          // If there isn't already a ClassInfo object for the outer class then we must insert one.
          // This ensures that we actually generate a declaration for the outer class, in which we
          // can forward-declare the inner class. "skip:true" ensures we do not generate bindings
          // for the outer class' member's, as we simply want to forward declare the inner class.
          outerClassInfo = new ClassInfo(String.format("%s = skip:true", outerClass.getName()));
          classMap.put(outerClass.getName(), outerClassInfo);
        }
        // Add the inner class to the outer class' mNestedClasses, ensuring the outer class'
        // generated code will forward-declare the inner class.
        outerClassInfo.mNestedClasses.add(innerClass);

        innerClass = outerClass;
      }
    }

    // Sort to ensure we generate the classes in a deterministic order, and that outer classes are
    // declared before nested classes.
    return classMap.values().stream()
        .sorted((a, b) -> a.name.compareTo(b.name))
        .toArray(ClassInfo[]::new);
  }

  private static void writeOutputFiles(
      String aOutputDir,
      String aPrefix,
      StringBuilder aHeaderFile,
      StringBuilder aImplementationFile) {
    FileOutputStream implStream = null;
    File f = new File(aOutputDir);
    f.mkdirs(); // Shouldn't throw.  If it fails, we'll error out just below.
    try {
      implStream = new FileOutputStream(new File(aOutputDir, aPrefix + ".cpp"));
      implStream.write(aImplementationFile.toString().getBytes());
    } catch (IOException e) {
      System.err.println("Unable to write " + aOutputDir + ". Perhaps a permissions issue?");
      e.printStackTrace(System.err);
    } finally {
      if (implStream != null) {
        try {
          implStream.close();
        } catch (IOException e) {
          System.err.println("Unable to close implStream due to " + e);
          e.printStackTrace(System.err);
        }
      }
    }

    FileOutputStream headerStream = null;
    try {
      headerStream = new FileOutputStream(new File(aOutputDir, aPrefix + ".h"));
      headerStream.write(aHeaderFile.toString().getBytes());
    } catch (IOException e) {
      System.err.println("Unable to write " + aOutputDir + ". Perhaps a permissions issue?");
      e.printStackTrace(System.err);
    } finally {
      if (headerStream != null) {
        try {
          headerStream.close();
        } catch (IOException e) {
          System.err.println("Unable to close headerStream due to " + e);
          e.printStackTrace(System.err);
        }
      }
    }
  }
}
