package com.hannesdorfmann.fragmentargs.processor;

import com.hannesdorfmann.fragmentargs.FragmentArgs;
import com.hannesdorfmann.fragmentargs.FragmentArgsInjector;
import com.hannesdorfmann.fragmentargs.annotation.Arg;
import com.hannesdorfmann.fragmentargs.annotation.FragmentWithArgs;
import com.hannesdorfmann.fragmentargs.bundler.ArgsBundler;
import com.hannesdorfmann.fragmentargs.repacked.com.squareup.javawriter.JavaWriter;

import java.io.IOException;
import java.io.Serializable;
import java.io.Writer;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Filer;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.Element;
import javax.lang.model.element.ElementKind;
import javax.lang.model.element.ExecutableElement;
import javax.lang.model.element.Modifier;
import javax.lang.model.element.PackageElement;
import javax.lang.model.element.TypeElement;
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.Diagnostic;
import javax.tools.JavaFileObject;

/**
 * This is the annotation processor for FragmentArgs
 *
 * @author Hannes Dorfmann
 */
public class ArgProcessor extends AbstractProcessor {

    private static final String CUSTOM_BUNDLER_BUNDLE_KEY =
            "com.hannesdorfmann.fragmentargs.custom.bundler.2312A478rand.";

    private static final Map<String, String> ARGUMENT_TYPES =
            new HashMap<String, String>(20);

    /**
     * Annotation Processor Option
     */
    private static final String OPTION_IS_LIBRARY = "fragmentArgsLib";

    /**
     * Should the builder be annotated with support annotations?
     */
    private static final String OPTION_SUPPORT_ANNOTATIONS = "fragmentArgsSupportAnnotations";

    /**
     * Pass a list of additional annotations to annotate the generated builder classes
     */
    private static final String OPTION_ADDITIONAL_BUILDER_ANNOTATIONS =
            "fragmentArgsBuilderAnnotations";

    /**
     * Enable/disable warning logs
     */
    private static final String OPTION_LOG_WARNINGS = "fragmentArgsLogWarnings";

    static {
        ARGUMENT_TYPES.put("java.lang.String", "String");
        ARGUMENT_TYPES.put("int", "Int");
        ARGUMENT_TYPES.put("java.lang.Integer", "Int");
        ARGUMENT_TYPES.put("long", "Long");
        ARGUMENT_TYPES.put("java.lang.Long", "Long");
        ARGUMENT_TYPES.put("double", "Double");
        ARGUMENT_TYPES.put("java.lang.Double", "Double");
        ARGUMENT_TYPES.put("short", "Short");
        ARGUMENT_TYPES.put("java.lang.Short", "Short");
        ARGUMENT_TYPES.put("float", "Float");
        ARGUMENT_TYPES.put("java.lang.Float", "Float");
        ARGUMENT_TYPES.put("byte", "Byte");
        ARGUMENT_TYPES.put("java.lang.Byte", "Byte");
        ARGUMENT_TYPES.put("boolean", "Boolean");
        ARGUMENT_TYPES.put("java.lang.Boolean", "Boolean");
        ARGUMENT_TYPES.put("char", "Char");
        ARGUMENT_TYPES.put("java.lang.Character", "Char");
        ARGUMENT_TYPES.put("java.lang.CharSequence", "CharSequence");
        ARGUMENT_TYPES.put("android.os.Bundle", "Bundle");
        ARGUMENT_TYPES.put("android.os.Parcelable", "Parcelable");
    }

    private Types typeUtils;
    private Filer filer;

    private TypeElement TYPE_FRAGMENT;
    private TypeElement TYPE_SUPPORT_FRAGMENT;
    private TypeElement TYPE_ANDROIDX_FRAGMENT;
    private boolean supportAnnotations = true;
    private boolean logWarnings = true;

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> supportTypes = new LinkedHashSet<String>();
        supportTypes.add(Arg.class.getCanonicalName());
        supportTypes.add(FragmentWithArgs.class.getCanonicalName());
        return supportTypes;
    }

    @Override
    public Set<String> getSupportedOptions() {
        Set<String> supportedOptions = new LinkedHashSet<String>();
        supportedOptions.add(OPTION_IS_LIBRARY);
        supportedOptions.add(OPTION_ADDITIONAL_BUILDER_ANNOTATIONS);
        supportedOptions.add(OPTION_SUPPORT_ANNOTATIONS);
        supportedOptions.add(OPTION_LOG_WARNINGS);
        return supportedOptions;
    }

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

        Elements elementUtils = env.getElementUtils();
        typeUtils = env.getTypeUtils();
        filer = env.getFiler();

        TYPE_FRAGMENT = elementUtils.getTypeElement("android.app.Fragment");
        TYPE_SUPPORT_FRAGMENT =
                elementUtils.getTypeElement("android.support.v4.app.Fragment");
        TYPE_ANDROIDX_FRAGMENT =
                elementUtils.getTypeElement("androidx.fragment.app.Fragment");
    }

    private String getOperation(ArgumentAnnotatedField arg) {
        String op = ARGUMENT_TYPES.get(arg.getRawType());
        if (op != null) {
            if (arg.isArray()) {
                return op + "Array";
            } else {
                return op;
            }
        }

        Elements elements = processingEnv.getElementUtils();
        TypeMirror type = arg.getElement().asType();
        Types types = processingEnv.getTypeUtils();
        String[] arrayListTypes = new String[]{
                String.class.getName(), Integer.class.getName(), CharSequence.class.getName()
        };
        String[] arrayListOps =
                new String[]{"StringArrayList", "IntegerArrayList", "CharSequenceArrayList"};
        for (int i = 0; i < arrayListTypes.length; i++) {
            TypeMirror tm = getArrayListType(arrayListTypes[i]);
            if (types.isAssignable(type, tm)) {
                return arrayListOps[i];
            }
        }

        if (types.isAssignable(type,
                getWildcardType(ArrayList.class.getName(), "android.os.Parcelable"))) {
            return "ParcelableArrayList";
        }
        TypeMirror sparseParcelableArray =
                getWildcardType("android.util.SparseArray", "android.os.Parcelable");

        if (types.isAssignable(type, sparseParcelableArray)) {
            return "SparseParcelableArray";
        }

        if (types.isAssignable(type, elements.getTypeElement("android.os.Parcelable").asType())) {
            return "Parcelable";
        }

        if (types.isAssignable(type, elements.getTypeElement(Serializable.class.getName()).asType())) {
            return "Serializable";
        }

        return null;
    }

    private TypeMirror getWildcardType(String type, String elementType) {
        TypeElement arrayList = processingEnv.getElementUtils().getTypeElement(type);
        TypeMirror elType = processingEnv.getElementUtils().getTypeElement(elementType).asType();
        return processingEnv.getTypeUtils()
                .getDeclaredType(arrayList, processingEnv.getTypeUtils().getWildcardType(elType, null));
    }

    private TypeMirror getArrayListType(String elementType) {
        TypeElement arrayList = processingEnv.getElementUtils().getTypeElement("java.util.ArrayList");
        TypeMirror elType = processingEnv.getElementUtils().getTypeElement(elementType).asType();
        return processingEnv.getTypeUtils().getDeclaredType(arrayList, elType);
    }

    private void writePutArguments(JavaWriter jw, String sourceVariable, String bundleVariable,
                                   ArgumentAnnotatedField arg) throws IOException, ProcessingException {

        boolean addNullCheck = !arg.isPrimitive() && !arg.isRequired();

        jw.emitEmptyLine();

        if (addNullCheck) {
            jw.beginControlFlow("if (%s != null)", sourceVariable);
        }

        if (arg.hasCustomBundler()) {
            jw.emitStatement("%s.putBoolean(\"%s\", true)", bundleVariable,
                    CUSTOM_BUNDLER_BUNDLE_KEY + arg.getKey());
            jw.emitStatement("%s.put(\"%s\", %s, %s)", arg.getBundlerFieldName(), arg.getKey(),
                    sourceVariable, bundleVariable);
        } else {

            String op = getOperation(arg);

            if (op == null) {
                throw new ProcessingException(arg.getElement(),
                        "Don't know how to put %s in a Bundle. This type is not supported by default. "
                                + "However, you can specify your own %s implementation in @Arg( bundler = YourBundler.class)",
                        arg.getElement().asType().toString(), ArgsBundler.class.getSimpleName());
            }

            if ("Serializable".equals(op)) {
                warn(arg.getElement(),
                    "%1$s will be stored as Serializable",
                    arg.getName()
                );
            }

            jw.emitStatement("%4$s.put%1$s(\"%2$s\", %3$s)", op, arg.getKey(), sourceVariable,
                    bundleVariable);
        }

        if (addNullCheck) {
            jw.endControlFlow();
        }
    }

    private void writePackage(JavaWriter jw, TypeElement type) throws IOException {
        PackageElement pkg = processingEnv.getElementUtils().getPackageOf(type);
        if (!pkg.isUnnamed()) {
            jw.emitPackage(pkg.getQualifiedName().toString());
        } else {
            jw.emitPackage("");
        }
    }

    /**
     * Scans for @Arg annotations in the class itself and all super classes (complete inheritance
     * hierarchy)
     */
    private AnnotatedFragment collectArgumentsForTypeInclSuperClasses(TypeElement type)
            throws ProcessingException {

        AnnotatedFragment fragment = new AnnotatedFragment(type);
        TypeElement currentClass = type;
        do {

            for (Element e : currentClass.getEnclosedElements()) {
                if (e.getKind() != ElementKind.FIELD) {
                    fragment.checkAndAddSetterMethod(e);
                    continue;
                }

                // It's a field
                Arg annotation = null;
                if ((annotation = e.getAnnotation(Arg.class)) != null) {
                    ArgumentAnnotatedField annotatedField =
                            new ArgumentAnnotatedField(e, (TypeElement) e.getEnclosingElement(), annotation);
                    addAnnotatedField(annotatedField, fragment, annotation);
                }
            }

            TypeMirror superClassType = currentClass.getSuperclass();
            if (superClassType.getKind() == TypeKind.NONE) {
                // Basis class (java.lang.Object) reached, so exit
                currentClass = null;
                break;
            } else {
                currentClass = (TypeElement) typeUtils.asElement(superClassType);
            }
        } while (currentClass != null);

        return fragment;
    }

    /**
     * Generates an error String with detailed information about that a field with the same name is
     * already defined in a super class
     */
    private String getErrorMessageDuplicatedField(AnnotatedFragment fragment,
                                                  TypeElement problemClass, String fieldName) {

        String base =
                "A field with the name '%s' in class %s is already annotated with @%s in super class %s ! "
                        + "Fields name must be unique within inheritance hierarchy.";

        // Assumption: The problemClass is already a super class of the real problem,
        // So determine the real problem by searching for the subclass that cause this problem
        TypeElement otherClass = null;
        for (ArgumentAnnotatedField otherField : fragment.getAll()) {
            if (otherField.getVariableName().equals(fieldName)) {
                otherClass = otherField.getClassElement();
                break;
            }
        }

        if (otherClass != null) {
            // Check who is the super class
            TypeElement currentClass = otherClass;

            while (currentClass != null) {
                TypeMirror currentClassSuperclass = currentClass.getSuperclass();
                if (currentClassSuperclass == null || currentClassSuperclass.getKind() == TypeKind.NONE) {
                    // They are not super classes
                    break;
                }

                if (currentClass.getQualifiedName() != null && currentClass.getQualifiedName()
                        .toString()
                        .equals(problemClass.getQualifiedName().toString())) {
                    // The problem causing class is a super class, so we found the superclass
                    // and the sub class that cause the problem
                    return String.format(base, fieldName, otherClass.getQualifiedName().toString(),
                            Arg.class.getSimpleName(), problemClass.getQualifiedName());
                }

                currentClass = (TypeElement) typeUtils.asElement(currentClassSuperclass);
            }
        }

        // Since the previous check wasn't successfull we can assume:
        // The problemClass must be a sub class, so find the super class that contains the field
        TypeMirror superClass = problemClass.getSuperclass();
        TypeElement superClassElement = null;

        if (superClass == null) {
            return String.format(
                    "A field with the name '%s' in class %s is already annotated with @%s in a super class or sub class! "
                            + "Fields name must be unique within inheritance hierarchy.", fieldName,
                    problemClass.getQualifiedName().toString(), Arg.class.getSimpleName());
        }

        boolean superClassFound = false;
        while (superClass != null
                && superClass.getKind() != TypeKind.NONE
                && (superClassElement = (TypeElement) typeUtils.asElement(superClass)) != null) {

            for (Element e : superClassElement.getEnclosedElements()) {
                if (e.getKind() == ElementKind.FIELD && e.getSimpleName() != null &&
                        e.getSimpleName().toString().equals(fieldName)) {
                    superClassFound = true;
                    break;
                }
            }

            if (superClassFound) {
                break;
            }
            superClass = superClassElement.getSuperclass();
        }

        if (superClassElement == null) {
            // Should never be the case, however to ensure we return a error message without superclass
            return String.format(
                    "A field with the name '%s' in class %s is already annotated with @%s in a "
                            + "super class or sub class of %s ! "
                            + "Fields name must be unique within inheritance hierarchy.", fieldName,
                    problemClass.getQualifiedName().toString(), Arg.class.getSimpleName(),
                    problemClass.getQualifiedName().toString());
        }

        return String.format(base, fieldName, problemClass.getQualifiedName().toString(),
                Arg.class.getSimpleName(), superClassElement.getQualifiedName());
    }

    /**
     * Checks if the annotated field can be added to the given fragment. Otherwise a error message
     * will be printed
     */
    private void addAnnotatedField(ArgumentAnnotatedField annotatedField, AnnotatedFragment fragment,
                                   Arg annotation) throws ProcessingException {

        if (fragment.containsField(annotatedField)) {
            // A field already with the name is here
            throw new ProcessingException(annotatedField.getElement(),
                    getErrorMessageDuplicatedField(fragment, annotatedField.getClassElement(),
                            annotatedField.getVariableName()));
        } else if (fragment.containsBundleKey(annotatedField) != null) {
            //  key for bundle is already in use
            ArgumentAnnotatedField otherField = fragment.containsBundleKey(annotatedField);
            throw new ProcessingException(annotatedField.getElement(),
                    "The bundle key '%s' for field %s in %s is already used by another "
                            + "argument in %s (field name is '%s'). Bundle keys must be unique in inheritance hierarchy!",
                    annotatedField.getKey(), annotatedField.getVariableName(),
                    annotatedField.getClassElement().getQualifiedName().toString(),
                    otherField.getClassElement().getQualifiedName().toString(), otherField.getVariableName());
        } else {
            if (annotation.required()) {
                fragment.addRequired(annotatedField);
            } else {
                fragment.addOptional(annotatedField);
            }
        }
    }

    /**
     * Checks if inheritance hiererachy should be scanned for @Args annotations as well
     *
     * @param type The Fragment class
     * @return true if super type should be scanned as well, otherwise false;
     */
    private boolean shouldScanSuperClassesFragmentArgs(TypeElement type) throws ProcessingException {

        boolean scanSuperClasses = true;

        FragmentWithArgs fragmentWithArgs = type.getAnnotation(FragmentWithArgs.class);
        if (fragmentWithArgs != null) {
            scanSuperClasses = fragmentWithArgs.inherited();
        }

        return scanSuperClasses; // Default value
    }

    /**
     * Collects the fields that are annotated by the fragmentarg
     */
    private AnnotatedFragment collectArgumentsForType(TypeElement type) throws ProcessingException {

        // incl. super classes
        if (shouldScanSuperClassesFragmentArgs(type)) {
            return collectArgumentsForTypeInclSuperClasses(type);
        }

        // Without super classes (inheritance)
        AnnotatedFragment fragment = new AnnotatedFragment(type);
        for (Element element : type.getEnclosedElements()) {
            if (element.getKind() == ElementKind.FIELD) {
                Arg annotation = element.getAnnotation(Arg.class);
                if (annotation != null) {
                    ArgumentAnnotatedField field = new ArgumentAnnotatedField(element, type, annotation);
                    addAnnotatedField(field, fragment, annotation);
                }
            } else {
                // check for setter
                fragment.checkAndAddSetterMethod(element);
            }
        }
        return fragment;
    }

    @Override
    public boolean process(Set<? extends TypeElement> type, RoundEnvironment env) {

        Types typeUtils = processingEnv.getTypeUtils();
        Filer filer = processingEnv.getFiler();

        //
        // Processor options
        //
        boolean isLibrary = false;
        String fragmentArgsLib = processingEnv.getOptions().get(OPTION_IS_LIBRARY);
        if (fragmentArgsLib != null && fragmentArgsLib.equalsIgnoreCase("true")) {
            isLibrary = true;
        }

        String supportAnnotationsStr = processingEnv.getOptions().get(OPTION_SUPPORT_ANNOTATIONS);
        if (supportAnnotationsStr != null && supportAnnotationsStr.equalsIgnoreCase("false")) {
            supportAnnotations = false;
        }

        String additionalBuilderAnnotations[] = {};
        String builderAnnotationsStr =
                processingEnv.getOptions().get(OPTION_ADDITIONAL_BUILDER_ANNOTATIONS);
        if (builderAnnotationsStr != null && builderAnnotationsStr.length() > 0) {
            additionalBuilderAnnotations = builderAnnotationsStr.split(" "); // White space is delimiter
        }

        String fragmentArgsLogWarnings = processingEnv.getOptions().get(OPTION_LOG_WARNINGS);
        if(fragmentArgsLogWarnings != null && fragmentArgsLogWarnings.equalsIgnoreCase("false")) {
            logWarnings = false;
        }

        String nonNullAnnotationImport = "";
        String nullableAnnotationImport = "";

        if(supportAnnotations) {
            if (isClassAvailable("android.support.annotation.NonNull")) {
                nonNullAnnotationImport = "android.support.annotation.NonNull";
                nullableAnnotationImport = "android.support.annotation.Nullable";

            } else if (isClassAvailable("androidx.annotation.NonNull")) {
                nonNullAnnotationImport = "androidx.annotation.NonNull";
                nullableAnnotationImport = "androidx.annotation.Nullable";

            } else {
                supportAnnotations = false;
                warn(null,
                        "Support annotations have been disabled because neither " +
                                "'android.support.annotation.NonNull' nor " +
                                "'androidx.annotation.NonNull' could be found during processing"
                );
            }
        }

        List<ProcessingException> processingExceptions = new ArrayList<ProcessingException>();

        JavaWriter jw = null;

        // REMEMBER: It's a SET! it uses .equals() .hashCode() to determine if element already in set
        Set<TypeElement> fragmentClasses = new HashSet<TypeElement>();

        Element[] origHelper = null;

        // Search for @Arg fields
        for (Element element : env.getElementsAnnotatedWith(Arg.class)) {

            try {
                TypeElement enclosingElement = (TypeElement) element.getEnclosingElement();

                // Check if its a fragment
                if (!isFragmentClass(enclosingElement)) {
                    throw new ProcessingException(element,
                            "@Arg can only be used on fragment fields (%s.%s)",
                            enclosingElement.getQualifiedName(), element);
                }

                if (element.getModifiers().contains(Modifier.FINAL)) {
                    throw new ProcessingException(element,
                            "@Arg fields must not be final (%s.%s)",
                            enclosingElement.getQualifiedName(), element);
                }

                if (element.getModifiers()
                        .contains(Modifier.STATIC)) {
                    throw new ProcessingException(element,
                            "@Arg fields must not be static (%s.%s)",
                            enclosingElement.getQualifiedName(), element);
                }

                // Skip abstract classes
                if (!enclosingElement.getModifiers().contains(Modifier.ABSTRACT)) {
                    fragmentClasses.add(enclosingElement);
                }
            } catch (ProcessingException e) {
                processingExceptions.add(e);
            }
        }

        // Search for "just" @FragmentWithArgs
        for (Element element : env.getElementsAnnotatedWith(FragmentWithArgs.class)) {
            try {
                scanForAnnotatedFragmentClasses(env, FragmentWithArgs.class, fragmentClasses, element);
            } catch (ProcessingException e) {
                processingExceptions.add(e);
            }
        }

        // Store the key - value for the generated FragmentArtMap class
        Map<String, String> autoMapping = new HashMap<String, String>();

        for (TypeElement fragmentClass : fragmentClasses) {

            JavaFileObject jfo = null;
            try {

                AnnotatedFragment fragment = collectArgumentsForType(fragmentClass);

                String builderName = fragment.getBuilderName();

                List<Element> originating = new ArrayList<Element>(10);
                originating.add(fragmentClass);
                TypeMirror superClass = fragmentClass.getSuperclass();
                while (superClass.getKind() != TypeKind.NONE) {
                    TypeElement element = (TypeElement) typeUtils.asElement(superClass);
                    if (element.getQualifiedName().toString().startsWith("android.")) {
                        break;
                    }
                    originating.add(element);
                    superClass = element.getSuperclass();
                }

                String qualifiedFragmentName = fragment.getQualifiedName();
                String qualifiedBuilderName = fragment.getQualifiedBuilderName();

                Element[] orig = originating.toArray(new Element[originating.size()]);
                origHelper = orig;

                jfo = filer.createSourceFile(qualifiedBuilderName, orig);
                Writer writer = jfo.openWriter();
                jw = new JavaWriter(writer);
                writePackage(jw, fragmentClass);
                jw.emitImports("android.os.Bundle");
                if (supportAnnotations) {
                    jw.emitImports(nonNullAnnotationImport);
                    if (!fragment.getOptionalFields().isEmpty()) {
                        jw.emitImports(nullableAnnotationImport);
                    }
                }

                // for inner classes we need to add an import
                if(fragment.isInnerClass()) {
                    jw.emitImports(fragment.getQualifiedName());
                }

                jw.emitEmptyLine();

                // Additional builder annotations
                for (String builderAnnotation : additionalBuilderAnnotations) {
                    jw.emitAnnotation(builderAnnotation);
                }

                jw.beginType(builderName, "class", EnumSet.of(Modifier.PUBLIC, Modifier.FINAL));

                if (!fragment.getBundlerVariableMap().isEmpty()) {
                    jw.emitEmptyLine();
                    for (Map.Entry<String, String> e : fragment.getBundlerVariableMap().entrySet()) {
                        jw.emitField(e.getKey(), e.getValue(),
                                EnumSet.of(Modifier.PRIVATE, Modifier.FINAL, Modifier.STATIC),
                                "new " + e.getKey() + "()");
                    }
                }
                jw.emitEmptyLine();
                jw.emitField("Bundle", "mArguments", EnumSet.of(Modifier.PRIVATE, Modifier.FINAL),
                        "new Bundle()");
                jw.emitEmptyLine();

                Set<ArgumentAnnotatedField> required = fragment.getRequiredFields();

                String[] args = new String[required.size() * 2];
                int index = 0;

                for (ArgumentAnnotatedField arg : required) {
                    boolean annotate = supportAnnotations && !arg.isPrimitive();
                    args[index++] = annotate ? "@NonNull " + arg.getType() : arg.getType();
                    args[index++] = arg.getVariableName();
                }
                jw.beginMethod(null, builderName, EnumSet.of(Modifier.PUBLIC), args);

                for (ArgumentAnnotatedField arg : required) {
                    writePutArguments(jw, arg.getVariableName(), "mArguments", arg);
                }

                jw.endMethod();

                if (!required.isEmpty()) {
                    jw.emitEmptyLine();
                    writeNewFragmentWithRequiredMethod(builderName, fragmentClass, jw, args);
                }

                Set<ArgumentAnnotatedField> optionalArguments = fragment.getOptionalFields();

                for (ArgumentAnnotatedField arg : optionalArguments) {
                    writeBuilderMethod(builderName, jw, arg);
                }

                jw.emitEmptyLine();
                writeBuildBundleMethod(jw);

                jw.emitEmptyLine();
                writeInjectMethod(jw, fragmentClass, fragment);

                jw.emitEmptyLine();
                writeBuildMethod(jw, fragmentClass);

                jw.endType();

                autoMapping.put(qualifiedFragmentName, qualifiedBuilderName);
            } catch (IOException e) {
                processingExceptions.add(
                        new ProcessingException(fragmentClass, "Unable to write builder for type %s: %s",
                                fragmentClass, e.getMessage()));
            } catch (ProcessingException e) {
                processingExceptions.add(e);
                if (jfo != null) {
                    jfo.delete();
                }
            } finally {
                if (jw != null) {
                    try {
                        jw.close();
                    } catch (IOException e1) {
                        processingExceptions.add(new ProcessingException(fragmentClass,
                                "Unable to close javawriter while generating builder for type %s: %s",
                                fragmentClass, e1.getMessage()));
                    }
                }
            }
        }

        // Write the automapping class
        if (origHelper != null && !isLibrary) {
            try {
                writeAutoMapping(autoMapping, origHelper);
            } catch (ProcessingException e) {
                processingExceptions.add(e);
            }
        }

        // Print errors
        for (ProcessingException e : processingExceptions) {
            error(e);
        }

        return true;
    }

    /**
     * Write the buildBundle() method
     *
     * @param jw The javawriter
     * @throws IOException
     */
    private void writeBuildBundleMethod(JavaWriter jw) throws IOException {
        if (supportAnnotations) jw.emitAnnotation("NonNull");
        jw.beginMethod("Bundle", "buildBundle", EnumSet.of(Modifier.PUBLIC));
        jw.emitStatement("return new Bundle(mArguments)");
        jw.endMethod();
    }

    /**
     * Scans a fragment for a given {@link FragmentWithArgs} annotation
     *
     * @param env             The round environment
     * @param annotationClass The annotation (.class) to scan for
     * @param fragmentClasses The set of classes already scanned (containing annotations)
     * @throws ProcessingException
     */
    private void scanForAnnotatedFragmentClasses(RoundEnvironment env,
                                                 Class<? extends Annotation> annotationClass, Set<TypeElement> fragmentClasses,
                                                 Element element)
            throws ProcessingException {

        if (element.getKind() != ElementKind.CLASS) {
            throw new ProcessingException(element, "%s can only be applied on Fragment classes",
                    annotationClass.getSimpleName());
        }

        TypeElement classElement = (TypeElement) element;

        // Check if its a fragment
        if (!isFragmentClass(element)) {
            throw new ProcessingException(element,
                    "%s can only be used on fragments, but %s is not a subclass of fragment",
                    annotationClass.getSimpleName(), classElement.getQualifiedName());
        }

        // Skip abstract classes
        if (!classElement.getModifiers().contains(Modifier.ABSTRACT)) {
            fragmentClasses.add(classElement);
        }
    }

    /**
     * Checks if the given element is in a valid Fragment class
     */
    private boolean isFragmentClass(Element classElement) {
        List<TypeElement> fragmentTypeElements = Arrays.asList(TYPE_FRAGMENT, TYPE_SUPPORT_FRAGMENT, TYPE_ANDROIDX_FRAGMENT);

        for (TypeElement fragmentTypeElement : fragmentTypeElements) {
            if(fragmentTypeElement != null && typeUtils.isSubtype(classElement.asType(),
                    fragmentTypeElement.asType())) {
                return true;
            }
        }

        return false;
    }

    private boolean isClassAvailable(String className) {
        try  {
            Class.forName(className);
            return true;
        }  catch (ClassNotFoundException e) {
            return false;
        }
    }

    /**
     * Key is the fully qualified fragment name, value is the fully qualified Builder class name
     */

    private void writeAutoMapping(Map<String, String> mapping, Element[] element)
            throws ProcessingException {

        try {
            JavaFileObject jfo =
                    filer.createSourceFile(FragmentArgs.AUTO_MAPPING_QUALIFIED_CLASS, element);
            Writer writer = jfo.openWriter();
            JavaWriter jw = new JavaWriter(writer);
            // Package
            jw.emitPackage(FragmentArgs.AUTO_MAPPING_PACKAGE);

            // Class
            jw.beginType(FragmentArgs.AUTO_MAPPING_CLASS_NAME, "class",
                    EnumSet.of(Modifier.PUBLIC, Modifier.FINAL), null,
                    FragmentArgsInjector.class.getCanonicalName());

            jw.emitEmptyLine();
            // The mapping Method
            jw.emitAnnotation("Override");
            jw.beginMethod("void", "inject", EnumSet.of(Modifier.PUBLIC), "Object", "target");

            jw.emitEmptyLine();
            jw.emitStatement("Class<?> targetClass = target.getClass()");
            jw.emitStatement("String targetName = targetClass.getCanonicalName()");
            // TODO should be targetClass.getName()? Inner anonymous class not possible?

            for (Map.Entry<String, String> entry : mapping.entrySet()) {

                jw.emitEmptyLine();
                jw.beginControlFlow("if ( %s.class.getName().equals(targetName) )", entry.getKey());
                jw.emitStatement("%s.injectArguments( ( %s ) target)", entry.getValue(), entry.getKey());
                jw.emitStatement("return");
                jw.endControlFlow();
            }

            // End Mapping method
            jw.endMethod();

            jw.endType();
            jw.close();
        } catch (IOException e) {
            throw new ProcessingException(null,
                    "Unable to write the automapping class for builder to fragment: %s: %s",
                    FragmentArgs.AUTO_MAPPING_QUALIFIED_CLASS, e.getMessage());
        }
    }

    private void writeNewFragmentWithRequiredMethod(String builder, TypeElement element,
                                                    JavaWriter jw, String[] args) throws IOException {

        if (supportAnnotations) jw.emitAnnotation("NonNull");
        jw.beginMethod(element.getQualifiedName().toString(), "new" + element.getSimpleName(),
                EnumSet.of(Modifier.STATIC, Modifier.PUBLIC), args);
        StringBuilder argNames = new StringBuilder();
        for (int i = 1; i < args.length; i += 2) {
            argNames.append(args[i]);
            if (i < args.length - 1) {
                argNames.append(", ");
            }
        }
        jw.emitStatement("return new %1$s(%2$s).build()", builder, argNames);
        jw.endMethod();
    }

    private void writeBuildMethod(JavaWriter jw, TypeElement element) throws IOException {
        if (supportAnnotations) {
            jw.emitAnnotation("NonNull");
        }

        jw.beginMethod(element.getSimpleName().toString(), "build", EnumSet.of(Modifier.PUBLIC));
        jw.emitStatement("%1$s fragment = new %1$s()", element.getSimpleName().toString());
        jw.emitStatement("fragment.setArguments(mArguments)");
        jw.emitStatement("return fragment");
        jw.endMethod();
    }

    private void writeInjectMethod(JavaWriter jw, TypeElement element,
                                   AnnotatedFragment fragment) throws IOException, ProcessingException {

        Set<ArgumentAnnotatedField> allArguments = fragment.getAll();

        String fragmentType = supportAnnotations ? "@NonNull " + element.getSimpleName().toString()
                : element.getSimpleName().toString();

        jw.beginMethod("void", "injectArguments",
                EnumSet.of(Modifier.PUBLIC, Modifier.FINAL, Modifier.STATIC),
                fragmentType, "fragment");


        jw.emitStatement("Bundle args = fragment.getArguments()");

        // Check if bundle is null only if at least one required field
        jw.beginControlFlow("if (args == null)");
        jw.emitStatement(
                "throw new IllegalStateException(\"No arguments set. Have you set up this Fragment with the corresponding FragmentArgs Builder? \")");
        jw.endControlFlow();


        int setterAssignmentHelperCounter = 0;
        for (ArgumentAnnotatedField field : allArguments) {
            jw.emitEmptyLine();

            Set<Modifier> modifiers = field.getElement().getModifiers();

            // Check if the given setter is available
            String setterMethod = null;

            // Private fields and non-public fields from a different package need a setter method
            boolean useSetter = modifiers.contains(Modifier.PRIVATE)
                    || (!getPackage(fragment.getClassElement()).equals(getPackage(field.getElement())) && !modifiers.contains(Modifier.PUBLIC));

            if (useSetter) {
                ExecutableElement setterMethodElement = fragment.findSetterForField(field);
                setterMethod = setterMethodElement.getSimpleName().toString();
            }

            // Args Bundler
            if (field.hasCustomBundler()) {

                String setterAssignmentHelperStr = null;
                String assignmentStr;
                if (useSetter) {
                    setterAssignmentHelperStr = field.getType()
                            + " value"
                            + setterAssignmentHelperCounter
                            + " =  %s.get(\"%s\", args)";
                    assignmentStr = "fragment.%s( value" + setterAssignmentHelperCounter + " )";
                    setterAssignmentHelperCounter++;
                } else {
                    assignmentStr = "fragment.%s = %s.get(\"%s\", args)";
                }

                // Required
                if (field.isRequired()) {
                    jw.beginControlFlow("if (!args.containsKey(" + JavaWriter.stringLiteral(
                            CUSTOM_BUNDLER_BUNDLE_KEY + field.getKey()) + "))");
                    jw.emitStatement("throw new IllegalStateException(\"required argument %1$s is not set\")",
                            field.getKey());
                    jw.endControlFlow();
                    if (useSetter) {
                        jw.emitStatement(setterAssignmentHelperStr, field.getBundlerFieldName(),
                                field.getKey());
                        jw.emitStatement(assignmentStr, setterMethod);
                    } else {
                        jw.emitStatement(assignmentStr, field.getName(),
                                field.getBundlerFieldName(), field.getKey());
                    }
                } else {
                    // not required bundler
                    jw.beginControlFlow("if (args.getBoolean(" + JavaWriter.stringLiteral(
                            CUSTOM_BUNDLER_BUNDLE_KEY + field.getKey()) + "))");

                    if (useSetter) {
                        jw.emitStatement(setterAssignmentHelperStr, field.getBundlerFieldName(),
                                field.getKey());
                        jw.emitStatement(assignmentStr, setterMethod);
                    } else {
                        jw.emitStatement(assignmentStr, field.getName(),
                                field.getBundlerFieldName(), field.getKey());
                    }

                    jw.endControlFlow();
                }
            } else {

                // Build in functions
                String op = getOperation(field);
                if (op == null) {
                    throw new ProcessingException(element,
                            "Can't write injector, the type is not supported by default. "
                                    + "However, You can provide your own implementation by providing an %s like this: @Arg( bundler = YourBundler.class )",
                            ArgsBundler.class.getSimpleName());
                }

                String cast = "Serializable".equals(op) ? "(" + field.getType() + ") " : "";
                if (!field.isRequired()) {
                    jw.beginControlFlow(
                            "if (args != null && args.containsKey("
                                    + JavaWriter.stringLiteral(field.getKey())
                                    + "))");
                } else {
                    jw.beginControlFlow(
                            "if (!args.containsKey(" + JavaWriter.stringLiteral(field.getKey()) + "))");
                    jw.emitStatement("throw new IllegalStateException(\"required argument %1$s is not set\")",
                            field.getKey());
                    jw.endControlFlow();
                }

                if (useSetter) {
                    jw.emitStatement(
                            "%1$s value" + setterAssignmentHelperCounter + " = %4$sargs.get%2$s(\"%3$s\")",
                            field.getType(), op,
                            field.getKey(), cast);
                    jw.emitStatement("fragment.%1$s(value" + setterAssignmentHelperCounter + ")",
                            setterMethod);
                    setterAssignmentHelperCounter++;
                } else {
                    jw.emitStatement("fragment.%1$s = %4$sargs.get%2$s(\"%3$s\")", field.getName(), op,
                            field.getKey(), cast);
                }

                if (!field.isRequired()) {
                    jw.endControlFlow();
                }
            }
        }
        jw.endMethod();
    }

    private void writeBuilderMethod(String type, JavaWriter writer, ArgumentAnnotatedField arg)
            throws IOException, ProcessingException {
        writer.emitEmptyLine();
        boolean annotate = supportAnnotations && !arg.isPrimitive();

        String typeStr;
        if (annotate) {
            if (arg.isRequired()) {
                typeStr = "@NonNull " + arg.getType();
            } else {
                typeStr = "@Nullable " + arg.getType();
            }
        } else {
            typeStr = arg.getType();
        }

        if (supportAnnotations) writer.emitAnnotation("NonNull");
        writer.beginMethod(type, arg.getVariableName(), EnumSet.of(Modifier.PUBLIC),
                typeStr, arg.getVariableName());
        writePutArguments(writer, arg.getVariableName(), "mArguments", arg);
        writer.emitStatement("return this");
        writer.endMethod();
    }

    private void error(ProcessingException e) {
        String message = e.getMessage();
        if (e.getMessageArgs().length > 0) {
            message = String.format(message, e.getMessageArgs());
        }
        processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, message, e.getElement());
    }

    private void warn(Element element, String message, Object... args) {
        if(logWarnings) {
            if (args.length > 0) {
                message = String.format(message, args);
            }
            processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING, message, element);
        }
    }

    private PackageElement getPackage(Element element) {
        while (element.getKind() != ElementKind.PACKAGE) {
            element = element.getEnclosingElement();
        }
        return (PackageElement) element;
    }

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