package io.crnk.gen.java;

import com.squareup.javapoet.AnnotationSpec;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.FieldSpec;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;
import io.crnk.core.queryspec.AbstractPathSpec;
import io.crnk.core.queryspec.PathSpec;
import io.crnk.core.queryspec.QuerySpec;
import io.crnk.core.queryspec.internal.typed.PrimitivePathSpec;
import io.crnk.core.queryspec.internal.typed.ResourcePathSpec;
import io.crnk.core.queryspec.internal.typed.TypedQuerySpec;
import io.crnk.core.resource.annotations.JsonApiEmbeddable;
import io.crnk.core.resource.annotations.JsonApiRelation;
import io.crnk.core.resource.annotations.JsonApiResource;

import javax.annotation.Generated;
import javax.annotation.processing.AbstractProcessor;
import javax.annotation.processing.Messager;
import javax.annotation.processing.ProcessingEnvironment;
import javax.annotation.processing.RoundEnvironment;
import javax.lang.model.SourceVersion;
import javax.lang.model.element.AnnotationMirror;
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.TypeElement;
import javax.lang.model.type.PrimitiveType;
import javax.lang.model.type.TypeMirror;
import javax.tools.Diagnostic;
import javax.tools.JavaFileObject;
import java.io.IOException;
import java.io.Writer;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * Generates type-safe {@link QuerySpec} and {@link PathSpec} classes by inspecting compiled resource classes.
 */
public class CrnkProcessor extends AbstractProcessor {

    private static final String PATH_SUFFIX = "PathSpec";

    private static final String QUERY_SUFFIX = "QuerySpec";

    @Override
    public void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
    }

    @Override
    public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnv) {
        Messager messager = processingEnv.getMessager();

        Set<? extends Element> resourceElements = roundEnv.getElementsAnnotatedWith(JsonApiResource.class);
        Set<? extends Element> embeddableElements = roundEnv.getElementsAnnotatedWith(JsonApiEmbeddable.class);
        Set<? extends Element> elements = new HashSet<>();
        elements.addAll((Set) resourceElements);
        elements.addAll((Set) embeddableElements);

        Set<String> resourceNames = new HashSet<>();
        for (Element element : elements) {
            TypeElement typeElement = (TypeElement) element;
            resourceNames.add(typeElement.getQualifiedName().toString());
        }

        for (Element element : elements) {
            if (element.getKind() != ElementKind.CLASS) {
                messager.printMessage(Diagnostic.Kind.ERROR, "Can be applied to class.");
                return true;
            }
            TypeElement resourceType = (TypeElement) element;
            buildPathType(resourceType);
            buildQueryType(resourceType);
        }
        return true;
    }

    private void buildQueryType(TypeElement resourceType) {
        AnnotationSpec annotationSpec = createGeneratedAnnotation();

        String packageName = getPackageName(resourceType);

        ParameterizedTypeName superType = ParameterizedTypeName.get(
                ClassName.get(TypedQuerySpec.class),
                ClassName.get(packageName, resourceType.getSimpleName().toString()),
                ClassName.get(packageName, getSimpleName(resourceType, false))
        );

        TypeSpec.Builder typeBuilder = TypeSpec.classBuilder(getSimpleName(resourceType, true))
                .addModifiers(Modifier.PUBLIC)
                .addAnnotation(annotationSpec)
                .superclass(superType);

        addQueryConstructors(resourceType, typeBuilder);

        write(resourceType, typeBuilder, true);
    }

    private void addQueryConstructors(TypeElement resourceType, TypeSpec.Builder typeBuilder) {
        String pathClassName = getSimpleName(resourceType.getSimpleName().toString(), false);

        MethodSpec.Builder defaultConstructor = MethodSpec.constructorBuilder()
                .addModifiers(Modifier.PUBLIC)
                .addStatement(String.format("super(%s.class, new %s())", resourceType.getQualifiedName(), pathClassName));
        typeBuilder.addMethod(defaultConstructor.build());
    }

    private void addBindMethod(TypeElement resourceType, TypeSpec.Builder typeBuilder) {
        String pathClassName = getSimpleName(resourceType, false);
        String packageName = getPackageName(resourceType);

        MethodSpec.Builder method = MethodSpec.methodBuilder("bindSpec")
                .addModifiers(Modifier.PROTECTED)
                .addParameter(AbstractPathSpec.class, "spec")
                .returns(ClassName.get(packageName, pathClassName))
                .addStatement(String.format("return new %s(spec)", pathClassName));
        typeBuilder.addMethod(method.build());
    }

    private void buildPathType(TypeElement resourceType) {
        AnnotationSpec annotationSpec = createGeneratedAnnotation();

        TypeSpec.Builder typeBuilder = TypeSpec.classBuilder(getSimpleName(resourceType, false))
                .addModifiers(Modifier.PUBLIC)
                .addAnnotation(annotationSpec)
                .superclass(ResourcePathSpec.class);

        addPathConstants(resourceType, typeBuilder);
        addPathConstructors(typeBuilder);
        addPathFields(resourceType, typeBuilder);
        addBindMethod(resourceType, typeBuilder);
        write(resourceType, typeBuilder, false);
    }

    private AnnotationSpec createGeneratedAnnotation() {
        return AnnotationSpec.builder(Generated.class).addMember("value", "\"Generated by Crnk annotation processor\"").build();
    }

    private void addPathConstants(TypeElement resourceType, TypeSpec.Builder typeBuilder) {
        TypeName typeName = ClassName.bestGuess(getSimpleName(resourceType, false));
        String name = firstToLower(getSimpleName(resourceType.getSimpleName().toString(), false));

        FieldSpec.Builder fieldBuilder = FieldSpec.builder(typeName, name, Modifier.STATIC, Modifier.PUBLIC);
        fieldBuilder.initializer("new " + typeName.toString() + "()");
        typeBuilder.addField(fieldBuilder.build());
    }

    private String firstToLower(String value) {
        return Character.toLowerCase(value.charAt(0)) + value.substring(1);
    }


    private void addPathConstructors(TypeSpec.Builder typeBuilder) {
        MethodSpec.Builder defaultConstructor = MethodSpec.constructorBuilder()
                .addModifiers(Modifier.PUBLIC)
                .addStatement(String.format("super(PathSpec.empty())"));
        typeBuilder.addMethod(defaultConstructor.build());

        MethodSpec.Builder pathConstructor = MethodSpec.constructorBuilder()
                .addModifiers(Modifier.PUBLIC)
                .addParameter(PathSpec.class, "pathSpec")
                .addStatement(String.format("super(pathSpec)"));
        typeBuilder.addMethod(pathConstructor.build());

        MethodSpec.Builder specConstructor = MethodSpec.constructorBuilder()
                .addModifiers(Modifier.PROTECTED)
                .addParameter(AbstractPathSpec.class, "spec")
                .addStatement(String.format("super(spec)"));
        typeBuilder.addMethod(specConstructor.build());
    }

    private String getSimpleName(Element resourceType, boolean queryClass) {
        return getSimpleName(resourceType.getSimpleName().toString(), queryClass);
    }

    private String getPackageName(Element resourceType) {
        return processingEnv.getElementUtils().getPackageOf(resourceType).getQualifiedName().toString();
    }

    private String getQualifiedName(Element resourceType, boolean query) {
        String packageName = getPackageName(resourceType);
        return packageName + (packageName.isEmpty() ? "" : ".") + getSimpleName(resourceType, query);
    }

    private void write(Element element, TypeSpec.Builder typeBuilder, boolean query) {
        try {
            String packageName = getPackageName(element);
            String qualifiedName = getQualifiedName(element, query);
            JavaFileObject sourceFile = processingEnv.getFiler().createSourceFile(qualifiedName, element);
            try (Writer writer = sourceFile.openWriter()) {
                JavaFile.builder(packageName, typeBuilder.build())
                        .indent(" ")
                        .build()
                        .writeTo(writer);
            }
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }

    private void addPathFields(TypeElement typeElement, TypeSpec.Builder typeBuilder) {
        List<? extends Element> elements = typeElement.getEnclosedElements();

        Set<String> relationships = new HashSet<>();
        for (Element member : elements) {
            String memberName = getMemberName(member);
            if (memberName != null && isRelationship(member)) {
                relationships.add(memberName);
            }
        }

        for (Element member : elements) {
            String memberName = getMemberName(member);
            if (memberName != null && member.getKind() == ElementKind.METHOD) {
                ExecutableElement executableElement = (ExecutableElement) member;
                TypeMirror memberType = executableElement.getReturnType();

                boolean embeddable = isEmbeddable(member);
                boolean isRelationship = relationships.contains(memberName);

                if (memberType instanceof PrimitiveType) {
                    memberType = processingEnv.getTypeUtils().boxedClass((PrimitiveType) memberType).asType();
                }

                TypeName pathImpl = ParameterizedTypeName.get(
                        ClassName.get(PrimitivePathSpec.class),
                        TypeName.get(memberType)
                );
                if (isRelationship || embeddable) {
                    pathImpl = getRelationshipType(memberType);
                }
                MethodSpec.Builder main = MethodSpec.methodBuilder(memberName)
                        .addModifiers(Modifier.PUBLIC)
                        .addStatement(String.format("PathSpec updatedPath = append(\"%s\")", memberName))
                        .addStatement(String.format("return boundSpec != null ? new %s(boundSpec) : new %s(updatedPath)", pathImpl.toString(), pathImpl.toString()))
                        .returns(pathImpl);
                typeBuilder.addMethod(main.build());
            }
        }

    }

    private boolean isRelationship(Element member) {
        for (AnnotationMirror mirror : member.getAnnotationMirrors()) {
            String name = mirror.getAnnotationType().toString();
            if (name.equals("javax.persistence.OneToMany") ||
                    name.equals("javax.persistence.ManyToOne") ||
                    name.equals("javax.persistence.OneToOne") ||
                    name.equals("javax.persistence.ManyToMany")) {
                return true;
            }
        }
        return member.getAnnotation(JsonApiRelation.class) != null;
    }

    private boolean isEmbeddable(Element member) {
        ExecutableElement executableElement = (ExecutableElement) member;
        TypeMirror returnType = executableElement.getReturnType();

        String propertyTypeName = returnType.toString();
        int sep = propertyTypeName.indexOf("<");
        if (sep != -1) {
            propertyTypeName = propertyTypeName.substring(sep + 1, propertyTypeName.length() - 1).trim();
        }

        TypeElement propertyType = processingEnv.getElementUtils().getTypeElement(propertyTypeName);
        if (propertyType == null) {
            //  processingEnv.getMessager().printMessage(Diagnostic.Kind.WARNING, "property type not found: " + propertyTypeName);
            return false;
        } else {
            JsonApiEmbeddable annotation = propertyType.getAnnotation(JsonApiEmbeddable.class);
            return annotation != null;
        }
    }

    private TypeName getRelationshipType(TypeMirror memberType) {
        String memberTypeStr = memberType.toString();
        int sep = memberTypeStr.indexOf("<");
        if (sep != -1) {
            memberTypeStr = memberTypeStr.substring(sep + 1, memberTypeStr.lastIndexOf(">"));
        }
        if (memberTypeStr.startsWith("()")) {
            memberTypeStr = memberTypeStr.substring(2);
        }
        memberTypeStr = getSimpleName(memberTypeStr, false);
        return ClassName.bestGuess(memberTypeStr);
    }

    private String getSimpleName(String name, boolean queryClass) {
        return normalizeName(name) + (queryClass ? QUERY_SUFFIX : PATH_SUFFIX);
    }

    private String normalizeName(String name) {
        if (name.endsWith("Entity")) {
            name = name.substring(0, name.length() - 6);
        }
        if (name.endsWith("Resource")) {
            name = name.substring(0, name.length() - 8);
        }
        return name;
    }

    private String getMemberName(Element member) {
        String memberName = member.getSimpleName().toString();
        if (member.getKind() == ElementKind.METHOD) {
            boolean isGetter = memberName.startsWith("get");
            boolean isBoolean = memberName.startsWith("is");
            if (isGetter || isBoolean) {
                String fieldName = memberName.substring(isGetter ? 3 : 2);
                return firstToLower(fieldName);
            }
            return null;
        } else if (member.getKind() == ElementKind.FIELD) {
            return memberName;
        }
        return null;
    }

    @Override
    public Set<String> getSupportedAnnotationTypes() {
        Set<String> set = new HashSet<>();
        set.add(JsonApiResource.class.getName());
        return set;
    }

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