/*
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

package com.instagram.common.json.annotation.processor;

import static com.instagram.common.json.annotation.processor.CodeFormatter.FIELD_ASSIGNMENT;
import static com.instagram.common.json.annotation.processor.CodeFormatter.FIELD_CODE_SERIALIZATION;
import static com.instagram.common.json.annotation.processor.CodeFormatter.VALUE_EXTRACT;
import static javax.lang.model.element.ElementKind.CLASS;
import static javax.lang.model.element.ElementKind.INTERFACE;
import static javax.lang.model.element.ElementKind.METHOD;
import static javax.lang.model.element.Modifier.ABSTRACT;
import static javax.lang.model.element.Modifier.PRIVATE;

import com.instagram.common.json.JsonAnnotationProcessorConstants;
import com.instagram.common.json.annotation.FromJson;
import com.instagram.common.json.annotation.JsonAdapter;
import com.instagram.common.json.annotation.JsonField;
import com.instagram.common.json.annotation.JsonType;
import com.instagram.common.json.annotation.ToJson;
import com.instagram.common.json.annotation.util.Console;
import com.instagram.common.json.annotation.util.ProcessorClassData;
import com.instagram.common.json.annotation.util.TypeUtils;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.annotation.processing.SupportedOptions;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.TypeElement;
import javax.lang.model.type.DeclaredType;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.Elements;
import javax.lang.model.util.Types;
import javax.tools.JavaFileObject;

/**
 * This annotation processor is run at compile time to find classes annotated with {@link JsonType}.
 * Deserializers are generated for such classes.
 */
@SupportedOptions({"generateSerializers"})
public class JsonAnnotationProcessor extends AbstractProcessor {
  private Messager mMessager;
  private Elements mElements;
  private Types mTypes;
  private Filer mFiler;
  private TypeUtils mTypeUtils;

  private boolean mGenerateSerializers;
  private boolean mOmitSomeMethodBodies;

  private static class State {
    private Map<TypeElement, JsonParserClassData> mClassElementToInjectorMap;

    State() {
      mClassElementToInjectorMap = new LinkedHashMap<>();
    }
  }

  private State mState;

  @Override
  public synchronized void init(ProcessingEnvironment env) {
    super.init(env);

    mMessager = env.getMessager();
    mElements = env.getElementUtils();
    mTypes = env.getTypeUtils();
    mFiler = env.getFiler();
    mTypeUtils = new TypeUtils(mTypes, mMessager);

    Map<String, String> options = env.getOptions();
    mGenerateSerializers = toBooleanDefaultTrue(options.get("generateSerializers"));
    mOmitSomeMethodBodies =
        toBooleanDefaultFalse(options.get("com.facebook.buck.java.generating_abi"));
  }

  private boolean toBooleanDefaultTrue(String value) {
    return value == null || !value.equalsIgnoreCase("false");
  }

  private boolean toBooleanDefaultFalse(String value) {
    return value != null && value.equalsIgnoreCase("true");
  }

  @Override
  public Set<String> getSupportedAnnotationTypes() {
    Set<String> supportTypes = new LinkedHashSet<String>();
    supportTypes.add(JsonField.class.getCanonicalName());
    supportTypes.add(JsonType.class.getCanonicalName());

    return supportTypes;
  }

  @Override
  public SourceVersion getSupportedSourceVersion() {
    return SourceVersion.latestSupported();
  }

  @Override
  public boolean process(Set<? extends TypeElement> elements, RoundEnvironment env) {
    Console.reportErrors(env, processingEnv.getMessager());

    try {
      // each round of processing requires a clean state.
      mState = new State();

      gatherClassAnnotations(env);
      if (!mOmitSomeMethodBodies) {
        // Field annotations are only needed if we're generating method bodies.
        gatherFieldAnnotations(env);
      }

      for (Map.Entry<TypeElement, JsonParserClassData> entry :
          mState.mClassElementToInjectorMap.entrySet()) {
        TypeElement typeElement = entry.getKey();
        JsonParserClassData injector = entry.getValue();

        try {
          JavaFileObject jfo = mFiler.createSourceFile(injector.getInjectedFqcn(), typeElement);
          Writer writer = jfo.openWriter();
          writer.write(injector.getJavaCode(processingEnv.getMessager()));
          writer.flush();
          writer.close();
        } catch (IOException e) {
          error(
              typeElement, "Unable to write injector for type %s: %s", typeElement, e.getMessage());
        }
      }

      return true;
    } catch (Throwable ex) {
      StringWriter sw = new StringWriter();
      ex.printStackTrace(new PrintWriter(sw));
      error("annotation exception: %s cause: %s", ex.toString(), sw.toString());
      return false;
    }
  }

  /** This finds the classes that are annotated with {@link JsonType}. */
  private void gatherClassAnnotations(RoundEnvironment env) {
    // Process each @TypeTesting elements.
    for (Element element : env.getElementsAnnotatedWith(JsonType.class)) {
      try {
        processClassAnnotation(element);
      } catch (Exception e) {
        StringWriter stackTrace = new StringWriter();
        e.printStackTrace(new PrintWriter(stackTrace));

        error(element, "Unable to generate injector for @JsonType.\n\n%s", stackTrace.toString());
      }
    }
  }

  /**
   * This processes a single class that is annotated with {@link JsonType}. It verifies that the
   * class is public and creates an {@link ProcessorClassData} for it.
   */
  private void processClassAnnotation(Element element) {
    boolean abstractClass = false;
    TypeElement typeElement = (TypeElement) element;

    // The annotation should be validated for an interface, but no code should be generated.
    JsonType annotation = element.getAnnotation(JsonType.class);
    if (element.getKind() == INTERFACE) {
      return;
    }

    boolean isKotlin = false;
    try {
      Class<? extends Annotation> metaDataClass =
          Class.forName("kotlin.Metadata").asSubclass(Annotation.class);
      isKotlin = element.getAnnotation(metaDataClass) != null;
    } catch (ClassNotFoundException e) {
      // not kotlin
    }

    // Verify containing class visibility is not private.
    if (element.getModifiers().contains(PRIVATE)) {
      error(
          element,
          "@%s %s may not be applied to private classes. (%s.%s)",
          JsonType.class.getSimpleName(),
          typeElement.getQualifiedName(),
          element.getSimpleName());
      return;
    }
    if (element.getModifiers().contains(ABSTRACT)) {
      abstractClass = true;
    }

    JsonParserClassData injector = mState.mClassElementToInjectorMap.get(typeElement);
    if (injector == null) {

      String parentGeneratedClassName = null;

      if (!mOmitSomeMethodBodies) {
        // Superclass info is only needed if we're generating method bodies.
        TypeMirror superclass = typeElement.getSuperclass();
        // walk up the superclass hierarchy until we find another class we know about.
        while (superclass.getKind() != TypeKind.NONE) {
          TypeElement superclassElement = (TypeElement) mTypes.asElement(superclass);

          if (superclassElement.getAnnotation(JsonType.class) != null) {
            String superclassPackageName = mTypeUtils.getPackageName(mElements, superclassElement);
            parentGeneratedClassName =
                superclassPackageName
                    + "."
                    + mTypeUtils.getPrefixForGeneratedClass(
                        superclassElement, superclassPackageName)
                    + JsonAnnotationProcessorConstants.HELPER_CLASS_SUFFIX;

            break;
          }

          superclass = superclassElement.getSuperclass();
        }
      }

      boolean generateSerializer =
          annotation.generateSerializer() == JsonType.TriState.DEFAULT
              ? mGenerateSerializers
              : annotation.generateSerializer() == JsonType.TriState.YES;

      String packageName = mTypeUtils.getPackageName(mElements, typeElement);
      injector =
          new JsonParserClassData(
              packageName,
              typeElement.getQualifiedName().toString(),
              mTypeUtils.getClassName(typeElement, packageName),
              mTypeUtils.getPrefixForGeneratedClass(typeElement, packageName)
                  + JsonAnnotationProcessorConstants.HELPER_CLASS_SUFFIX,
              new ProcessorClassData.AnnotationRecordFactory<String, TypeData>() {

                @Override
                public TypeData createAnnotationRecord(String key) {
                  return new TypeData();
                }
              },
              abstractClass,
              generateSerializer,
              mOmitSomeMethodBodies,
              parentGeneratedClassName,
              annotation,
              isKotlin);
      mState.mClassElementToInjectorMap.put(typeElement, injector);
    }
  }

  /** This finds the fields that are annotated with {@link JsonField}. */
  private void gatherFieldAnnotations(RoundEnvironment env) {
    // Process each @TypeTesting elements.
    for (Element element : env.getElementsAnnotatedWith(JsonField.class)) {
      try {
        processFieldAnnotation(element);
      } catch (Exception e) {
        StringWriter stackTrace = new StringWriter();
        e.printStackTrace(new PrintWriter(stackTrace));

        error(
            element,
            "Unable to generate view injector for @JsonField.\n\n%s",
            stackTrace.toString());
      }
    }
  }

  /**
   * This processes a single field annotated with {@link JsonField}. It locates the enclosing class
   * and then gathers data on the declared type of the field.
   */
  private void processFieldAnnotation(Element element) {
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Verify common generated code restrictions.
    if (!isFieldAnnotationValid(JsonField.class, element)) {
      return;
    }

    TypeMirror type = element.asType();

    JsonParserClassData injector = mState.mClassElementToInjectorMap.get(enclosingElement);

    TypeData data = injector.getOrCreateRecord(element.getSimpleName().toString());

    JsonField annotation = element.getAnnotation(JsonField.class);

    data.setFieldName(annotation.fieldName());
    data.setAlternateFieldNames(annotation.alternateFieldNames());
    data.setMapping(annotation.mapping());
    data.setValueExtractFormatter(VALUE_EXTRACT.forString(annotation.valueExtractFormatter()));
    data.setAssignmentFormatter(FIELD_ASSIGNMENT.forString(annotation.fieldAssignmentFormatter()));
    data.setSerializeCodeFormatter(
        FIELD_CODE_SERIALIZATION.forString(annotation.serializeCodeFormatter()));
    TypeUtils.CollectionType collectionType = mTypeUtils.getCollectionType(type);
    data.setCollectionType(collectionType);

    if (collectionType != TypeUtils.CollectionType.NOT_A_COLLECTION) {
      // inspect the inner type.
      type = mTypeUtils.getCollectionParameterizedType(type);
    }

    data.setParseType(mTypeUtils.getParseType(type, JsonType.class));

    boolean skipEnumValidationCheck = setJsonAdapterIfApplicable(type, injector, data, annotation);

    if (data.getParseType() == TypeUtils.ParseType.PARSABLE_OBJECT) {
      TypeMirror erasedType = mTypes.erasure(type);
      DeclaredType declaredType = (DeclaredType) erasedType;
      TypeElement typeElement = (TypeElement) declaredType.asElement();

      String packageName = mTypeUtils.getPackageName(mElements, typeElement);

      data.setPackageName(packageName);
      data.setParsableType(mTypeUtils.getClassName(typeElement, packageName));
      data.setParsableTypeParserClass(
          mTypeUtils.getPrefixForGeneratedClass(typeElement, packageName));

      JsonType typeAnnotation = typeElement.getAnnotation(JsonType.class);
      // Use the parsable object's value extract formatter if existing one is empty
      data.setValueExtractFormatter(
          data.getValueExtractFormatter()
              .orIfEmpty(VALUE_EXTRACT.forString(typeAnnotation.valueExtractFormatter())));

      CodeFormatter.Factory serializeCodeType =
          typeElement.getKind() == INTERFACE
              ? CodeFormatter.CLASS_CODE_SERIALIZATION
              : CodeFormatter.INTERFACE_CODE_SERIALIZATION;
      data.setSerializeCodeFormatter(
          data.getSerializeCodeFormatter()
              .orIfEmpty(serializeCodeType.forString(typeAnnotation.serializeCodeFormatter())));

      data.setIsInterface(typeElement.getKind() == INTERFACE);
      data.setFormatterImports(typeAnnotation.typeFormatterImports());
    } else if (data.getParseType() == TypeUtils.ParseType.ENUM_OBJECT) {
      // verify that we have value extract and serializer formatters.
      if (!skipEnumValidationCheck
          && (StringUtil.isNullOrEmpty(annotation.valueExtractFormatter())
              || (injector.generateSerializer()
                  && StringUtil.isNullOrEmpty(annotation.serializeCodeFormatter())))) {
        error(
            element,
            "%s: Annotate the enum with @%s (see annotation docs for details). "
                + "If that is undesirable you must have a value extract formatter, "
                + "and a serialize code formatter if serialization generation is enabled",
            enclosingElement,
            JsonAdapter.class.getSimpleName());
      }
      data.setEnumType(type.toString());
    }
  }

  /**
   * Sets up JsonAdapter data for the annotation processor if applicable.
   *
   * @return true if we can skip enum validation of formatters, as we do not need them if we have a
   *     json adapter, false otherwise
   */
  private boolean setJsonAdapterIfApplicable(
      TypeMirror type, JsonParserClassData injector, TypeData data, JsonField annotation) {
    // If there are custom formatters applied, it takes precedence over the json adapter of the
    // type.
    boolean eligibleToUseJsonAdapter =
        data.getParseType() == TypeUtils.ParseType.ENUM_OBJECT
            && annotation.valueExtractFormatter().isEmpty()
            && annotation.fieldAssignmentFormatter().isEmpty()
            && annotation.serializeCodeFormatter().isEmpty();

    boolean skipEnumValidationCheck = false;

    if (eligibleToUseJsonAdapter) {
      DeclaredType declaredType = (DeclaredType) type;
      Element typeElement = declaredType.asElement();
      JsonAdapter adapterAnnotation = typeElement.getAnnotation(JsonAdapter.class);
      if (adapterAnnotation != null) {
        TypeElement adapterTypeElement =
            AnnotationMirrorUtils.getAnnotationValueAsTypeElement(
                typeElement, mTypes, JsonAdapter.class, "adapterClass");

        ExecutableElement fromJson = null;
        ExecutableElement toJson = null;

        for (Element enclosedElement : adapterTypeElement.getEnclosedElements()) {
          if (enclosedElement.getKind() == METHOD) {
            if (enclosedElement.getAnnotation(FromJson.class) != null) {
              fromJson = (ExecutableElement) enclosedElement;
            } else if (enclosedElement.getAnnotation(ToJson.class) != null) {
              toJson = (ExecutableElement) enclosedElement;
            }
          }
          if (fromJson != null && (!injector.generateSerializer() || toJson != null)) {
            break;
          }
        }

        String qualifiedName = adapterTypeElement.getQualifiedName().toString();

        TypeMirror fromJsonParameterTypeMirror = null;

        // handle fromJson
        if (fromJson == null) {
          error(
              "%s: method with @%s annotation must be present",
              type, FromJson.class.getSimpleName());
        } else if (!mTypes.isSameType(fromJson.getReturnType(), type)) {
          error(
              fromJson,
              "@%s must return the correct type, expected type: %s",
              FromJson.class.getSimpleName(),
              type);
        } else if (fromJson.getParameters().size() != 1) {
          error(
              fromJson,
              "%s: @%s must have exactly one parameter, the json type expected (String, Integer, etc.)",
              type,
              FromJson.class.getSimpleName());
        } else {
          fromJsonParameterTypeMirror = fromJson.getParameters().get(0).asType();
          data.setJsonAdapterFromJsonMethod(
              qualifiedName + "." + fromJson.getSimpleName().toString());
        }

        // handle toJson
        if (injector.generateSerializer() && fromJsonParameterTypeMirror != null) {
          if (toJson == null) {
            error(
                "%s: method with @%s annotation must be present",
                type, ToJson.class.getSimpleName());
          } else if (toJson.getParameters().size() != 1) {
            error(
                toJson,
                "%s: @%s must have exactly one parameter, the type of the field.",
                type,
                ToJson.class.getSimpleName());
          } else if (!mTypes.isSameType(toJson.getParameters().get(0).asType(), type)) {
            error(
                toJson,
                "@%s must take the correct type, expected type: %s",
                ToJson.class.getSimpleName(),
                type);
          } else if (!mTypes.isSameType(toJson.getReturnType(), fromJsonParameterTypeMirror)) {
            error(
                fromJson,
                "@%s must return the correct type, expected type: %s",
                ToJson.class.getSimpleName(),
                fromJsonParameterTypeMirror);
          } else {
            data.setJsonAdapterToJsonMethod(
                qualifiedName + "." + toJson.getSimpleName().toString());
          }
        }
        data.setJsonAdapterParseType(mTypeUtils.getParseType(fromJsonParameterTypeMirror, null));
        skipEnumValidationCheck = true;
      }
    }
    return skipEnumValidationCheck;
  }

  private boolean isFieldAnnotationValid(
      Class<? extends Annotation> annotationClass, Element element) {
    TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

    // Verify containing type.
    if (enclosingElement.getKind() != CLASS) {
      error(
          enclosingElement,
          "@%s field may only be contained in classes. (%s.%s)",
          annotationClass.getSimpleName(),
          enclosingElement.getQualifiedName(),
          element.getSimpleName());
      return false;
    }

    Annotation annotation = enclosingElement.getAnnotation(JsonType.class);
    if (annotation == null) {
      error(
          enclosingElement,
          "@%s field may only be contained in classes annotated with @%s (%s.%s)",
          annotationClass.getSimpleName(),
          JsonType.class.toString(),
          enclosingElement.getQualifiedName(),
          element.getSimpleName());
      return false;
    }

    // Verify containing class visibility is not private.
    if (enclosingElement.getModifiers().contains(PRIVATE)) {
      error(
          enclosingElement,
          "@%s %s may not be contained in private classes. (%s.%s)",
          annotationClass.getSimpleName(),
          enclosingElement.getQualifiedName(),
          element.getSimpleName());
      return false;
    }

    return true;
  }

  private void error(String message, Object... args) {
    Console.error(processingEnv.getMessager(), message, args);
  }

  private void error(Element element, String message, Object... args) {
    Console.error(processingEnv.getMessager(), element, message, args);
  }

  private void warning(String message, Object... args) {
    Console.warning(processingEnv.getMessager(), message, args);
  }
}