/*
 * Copyright 2017 requery.io
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package io.requery.processor;

import io.requery.Embedded;
import io.requery.Entity;
import io.requery.Factory;
import io.requery.PropertyNameStyle;
import io.requery.PropertyVisibility;
import io.requery.ReadOnly;
import io.requery.Table;
import io.requery.Transient;
import io.requery.View;

import javax.annotation.processing.ProcessingEnvironment;
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.Name;
import javax.lang.model.element.NestingKind;
import javax.lang.model.element.TypeElement;
import javax.lang.model.element.VariableElement;
import javax.lang.model.type.MirroredTypeException;
import javax.lang.model.type.TypeKind;
import javax.lang.model.type.TypeMirror;
import javax.lang.model.util.ElementFilter;
import javax.lang.model.util.Elements;
import javax.persistence.Cacheable;
import javax.persistence.Embeddable;
import javax.persistence.Index;
import javax.tools.Diagnostic;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * Processes class level annotations on an abstract entity type.
 *
 * @author Nikhil Purushe
 */
class EntityType extends BaseProcessableElement<TypeElement> implements EntityElement {

    private final ProcessingEnvironment processingEnvironment;
    private final Set<AttributeDescriptor> attributes;
    private final Map<Element, ListenerMethod> listeners;

    EntityType(ProcessingEnvironment processingEnvironment, TypeElement typeElement) {
        super(typeElement);
        this.processingEnvironment = processingEnvironment;
        attributes = new LinkedHashSet<>();
        listeners = new LinkedHashMap<>();
    }

    @Override
    public Set<ElementValidator> process(ProcessingEnvironment processingEnvironment) {
        // create attributes for fields that have no annotations
        if (element().getKind().isInterface() || isImmutable() || isUnimplementable()) {
            ElementFilter.methodsIn(element().getEnclosedElements()).stream()
                .filter(this::isMethodProcessable)
                .forEach(this::computeAttribute);
        } else {
            // private/static/final members fields are skipped
            Set<VariableElement> elements = ElementFilter.fieldsIn(element().getEnclosedElements())
                    .stream()
                    .filter(element -> !element.getModifiers().contains(Modifier.PRIVATE) &&
                            !element.getModifiers().contains(Modifier.STATIC) &&
                            (!element.getModifiers().contains(Modifier.FINAL) || isImmutable()))
                    .collect(Collectors.toSet());

            if (elements.isEmpty()) { // if nothing to process try the getters instead
                ElementFilter.methodsIn(element().getEnclosedElements()).stream()
                        .filter(this::isMethodProcessable)
                        .forEach(this::computeAttribute);
            } else {
                elements.forEach(this::computeAttribute);
            }
        }
        // find listener annotated methods
        ElementFilter.methodsIn(element().getEnclosedElements()).forEach(element ->
                ListenerAnnotations.all().forEach(annotation -> {
            if (element.getAnnotation(annotation) != null) {
                ListenerMethod listener = listeners.computeIfAbsent(element,
                    key -> new ListenerMethod(element));
                listener.annotations().put(annotation, element.getAnnotation(annotation));
            }
        }));

        Set<ProcessableElement<?>> elements = new LinkedHashSet<>();
        attributes().forEach(
            attribute -> elements.add((ProcessableElement<?>) attribute));

        elements.addAll(listeners.values());
        Set<ElementValidator> validations = new LinkedHashSet<>();
        elements.forEach(element -> validations.addAll(element.process(processingEnvironment)));

        ElementValidator validator = new ElementValidator(element(), processingEnvironment);
        Entity entity = annotationOf(Entity.class).orElse(null);
        if (entity != null &&
            !Names.isEmpty(entity.name()) && !SourceVersion.isIdentifier(entity.name())) {
            validator.error("Invalid class identifier " + entity.name(), Entity.class);
        }
        if (element().getNestingKind() == NestingKind.ANONYMOUS) {
            validator.error("Entity annotation cannot be applied to anonymous class");
        }
        if (element().getKind() == ElementKind.ENUM) {
            validator.error("Entity annotation cannot be applied to an enum class");
        }
        if (attributes.isEmpty()) {
            validator.warning("Entity contains no attributes");
        }
        if (!isReadOnly() && !isEmbedded() && attributes.size() == 1 &&
            attributes.iterator().next().isGenerated()) {
            validator.warning(
                "Entity contains only a single generated attribute may fail to persist");
        }
        checkReserved(tableName(), validator);
        validations.add(validator);
        return validations;
    }

    private boolean isMethodProcessable(ExecutableElement element) {
        // if an immutable type with an implementation provided skip it
        if (!isUnimplementable() && element().getKind().isClass() && isImmutable() &&
            !element.getModifiers().contains(Modifier.ABSTRACT)) {
            if (!ImmutableAnnotationKind.of(element()).isPresent() ||
                !ImmutableAnnotationKind.of(element()).get().hasAnyMemberAnnotation(element)) {
                return false;
            }
        }
        String name = element.getSimpleName().toString();
        // skip kotlin data class methods with component1, component2.. names
        if (isUnimplementable() &&
            name.startsWith("component") && name.length() > "component".length()) {
            return false;
        }

        TypeMirror type = element.getReturnType();
        boolean isInterface = element().getKind().isInterface();
        boolean isTransient = Mirrors.findAnnotationMirror(element, Transient.class).isPresent();

        // must be a getter style method with no args, can't return void or itself or its builder
        return type.getKind() != TypeKind.VOID &&
               element.getParameters().isEmpty() &&
               (isImmutable() || isInterface || !element.getModifiers().contains(Modifier.FINAL)) &&
               (!isImmutable() || !type.equals(element().asType())) &&
               !type.equals(builderType().orElse(null)) &&
               !element.getModifiers().contains(Modifier.STATIC) &&
               !element.getModifiers().contains(Modifier.DEFAULT) &&
               (!isTransient || isInterface) &&
               !name.equals("toString") && !name.equals("hashCode");
    }

    @Override
    public void addAnnotationElement(TypeElement annotationElement, Element annotatedElement) {
        String qualifiedName = annotationElement.getQualifiedName().toString();
        Class<? extends Annotation> type;
        try {
            type = Class.forName(qualifiedName).asSubclass(Annotation.class);
        } catch (ClassNotFoundException e) {
            return;
        }
        switch (annotatedElement.getKind()) {
            case CLASS:
            case INTERFACE:
                annotations().put(type, annotatedElement.getAnnotation(type));
                break;
            case FIELD:
                if(annotatedElement.getModifiers().contains(Modifier.STATIC) ||
                   annotatedElement.getModifiers().contains(Modifier.FINAL)) {
                    // check if this a requery annotation
                    String packageName = Entity.class.getPackage().getName();
                    if (annotationElement.getQualifiedName().toString().startsWith(packageName)) {
                        processingEnvironment.getMessager().printMessage(Diagnostic.Kind.ERROR,
                                annotationElement.getQualifiedName() +
                                    " not applicable to static or final member", annotatedElement);
                    }
                } else {
                    VariableElement element = (VariableElement) annotatedElement;
                    Optional<AttributeMember> attribute = computeAttribute(element);
                    Annotation annotation = annotatedElement.getAnnotation(type);
                    attribute.ifPresent(a -> a.annotations().put(type, annotation));
                }
                break;
            case METHOD:
                ExecutableElement element = (ExecutableElement) annotatedElement;
                Annotation annotation = annotatedElement.getAnnotation(type);

                if (ListenerAnnotations.all().anyMatch(a -> a.equals(type))) {
                    ListenerMethod listener = listeners.computeIfAbsent(element,
                        key -> new ListenerMethod(element));
                    listener.annotations().put(type, annotation);
                } else if (isMethodProcessable(element)) {
                    Optional<AttributeMember> attribute = computeAttribute(element);
                    attribute.ifPresent(a -> a.annotations().put(type, annotation));
                }
                break;
        }
    }

    private Optional<AttributeMember> computeAttribute(Element element) {
        AttributeMember attribute = new AttributeMember(element, this);
        if (attributes.stream().noneMatch( a -> a.name().equals(attribute.name()))) {
            attributes.add(attribute);
            return Optional.of(attribute);
        } else {
            return Optional.empty();
        }
    }

    @Override
    public void merge(EntityDescriptor from) {
        from.attributes().forEach(entry -> {
            // add this attribute if an attribute with the same name is not already existing
            if (attributes.stream().noneMatch(
                    attribute -> attribute.name().equals(entry.name()))) {
                attributes.add(new AttributeMember(entry.element(), this));
            }
        });
        from.listeners().entrySet().stream()
            .filter(entry -> entry.getValue() instanceof ListenerMethod)
            .forEach(entry -> {
                ListenerMethod method = (ListenerMethod) entry.getValue();
                if (listeners.values().stream().noneMatch(
                    listener -> listener.element().getSimpleName()
                        .equals(method.element().getSimpleName()))) {
                    listeners.put(entry.getKey(), method);
                }
        });
    }

    @Override
    public boolean generatesAdditionalTypes() {
        return attributes.stream().anyMatch(member -> member.associativeEntity().isPresent());
    }

    private void checkReserved(String name, ElementValidator validator) {
        if (Stream.of(ReservedKeyword.values())
                .anyMatch(keyword -> keyword.toString().equalsIgnoreCase(name))) {
            validator.warning("Table or view name " + name + " may need to be escaped");
        }
    }

    @Override
    public Collection<? extends AttributeDescriptor> attributes() {
        return attributes;
    }

    @Override
    public Map<Element, ? extends ListenerDescriptor> listeners() {
        return listeners;
    }

    @Override
    public String modelName() {
        // it's important that the AnnotationMirror is used here since the model name needs to be
        // known before process() is called
        if (Mirrors.findAnnotationMirror(element(), Entity.class).isPresent()) {
            return Mirrors.findAnnotationMirror(element(), Entity.class)
                    .flatMap(mirror -> Mirrors.findAnnotationValue(mirror, "model"))
                    .map(value -> value.getValue().toString())
                    .filter(name -> !Names.isEmpty(name))
                    .orElse("default");
        } else if (Mirrors.findAnnotationMirror(element(),
                javax.persistence.Entity.class).isPresent()) {
            Elements elements = processingEnvironment.getElementUtils();
            Name packageName = elements.getPackageOf(element()).getQualifiedName();
            String[] parts = packageName.toString().split("\\.");
            return parts[parts.length - 1];
        }
        return "";
    }

    @Override
    public QualifiedName typeName() {
        String entityName = Stream.of(
            Mirrors.findAnnotationMirror(element(), Entity.class),
            Mirrors.findAnnotationMirror(element(), javax.persistence.Entity.class))
            .filter(Optional::isPresent)
            .map(Optional::get)
            .map(mirror -> Mirrors.findAnnotationValue(mirror, "name"))
            .filter(Optional::isPresent)
            .map(Optional::get)
            .map(value -> value.getValue().toString())
            .filter(name -> !Names.isEmpty(name))
            .findAny().orElse("");

        Elements elements = processingEnvironment.getElementUtils();
        String packageName = elements.getPackageOf(element()).getQualifiedName().toString();
        // if set in the annotation just use that
        if (!Names.isEmpty(entityName)) {
            return new QualifiedName(packageName, entityName);
        }
        String typeName = element().getSimpleName().toString();
        if (element().getKind().isInterface()) {
            // maybe I<Something> style
            if (typeName.startsWith("I") && Character.isUpperCase(typeName.charAt(1))) {
                entityName = typeName.substring(1);
            } else {
                entityName = typeName + "Entity";
            }
        } else {
            entityName = Names.removeClassPrefixes(typeName);
            if (entityName.equals(typeName)) {
                entityName = typeName + (isImmutable() || isUnimplementable() ? "Type" : "Entity");
            }
        }
        return new QualifiedName(packageName, entityName);
    }

    @Override
    public PropertyNameStyle propertyNameStyle() {
        return annotationOf(Entity.class)
                .map(Entity::propertyNameStyle)
                .orElse(PropertyNameStyle.BEAN);
    }

    @Override
    public PropertyVisibility propertyVisibility() {
        return annotationOf(Entity.class)
                .map(Entity::propertyVisibility)
                .orElse(PropertyVisibility.PRIVATE);
    }

    @Override
    public String tableName() {
        return annotationOf(Table.class).map(Table::name)
                .orElse( annotationOf(javax.persistence.Table.class)
                         .map(javax.persistence.Table::name)
                .orElse( annotationOf(View.class).map(View::name)
                .orElse( element().getKind().isInterface() || isImmutable() ?
                    element().getSimpleName().toString() :
                    Names.removeClassPrefixes(element().getSimpleName()))));
    }

    @Override
    public String[] tableAttributes() {
        return annotationOf(Table.class).map(Table::createAttributes).orElse(new String[]{});
    }

    @Override
    public String[] tableUniqueIndexes() {
        if (annotationOf(javax.persistence.Table.class).isPresent()) {
            Index[] indexes = annotationOf(javax.persistence.Table.class)
                    .map(javax.persistence.Table::indexes)
                    .orElse(new Index[0]);
            Set<String> names = Stream.of(indexes).filter(Index::unique)
                    .map(Index::name).collect(Collectors.toSet());
            return names.toArray(new String[names.size()]);
        }
        return annotationOf(Table.class).map(Table::uniqueIndexes).orElse(new String[]{});
    }

    @Override
    public boolean isCacheable() {
        return annotationOf(Entity.class).map(Entity::cacheable)
            .orElse( annotationOf(Cacheable.class).map(Cacheable::value).orElse(true));
    }

    @Override
    public boolean isCopyable() {
        return annotationOf(Entity.class).map(Entity::copyable).orElse(false);
    }

    @Override
    public boolean isImmutable() {
        // check known immutable type annotations then check the annotation value
        return ImmutableAnnotationKind.of(element()).isPresent() || isUnimplementable() ||
                annotationOf(Entity.class).map(Entity::immutable).orElse(false);
    }

    @Override
    public boolean isReadOnly() {
        return annotationOf(ReadOnly.class).isPresent();
    }

    @Override
    public boolean isStateless() {
        return isImmutable() || isUnimplementable() ||
            annotationOf(Entity.class).map(Entity::stateless).orElse(false);
    }

    @Override
    public boolean isView() {
        return annotationOf(View.class).isPresent();
    }

    @Override
    public boolean isUnimplementable() {
        boolean extendable = annotationOf(Entity.class).map(Entity::extendable).orElse(true);
        return !extendable || (element().getKind().isClass() &&
            element().getModifiers().contains(Modifier.FINAL));
    }

    @Override
    public boolean isEmbedded() {
        return annotationOf(Embedded.class).isPresent() ||
            annotationOf(Embeddable.class).isPresent();
    }

    @Override
    public Optional<TypeMirror> builderType() {
        Optional<Entity> entityAnnotation = annotationOf(Entity.class);
        if (entityAnnotation.isPresent()) {
            Entity entity = entityAnnotation.get();
            Elements elements = processingEnvironment.getElementUtils();
            TypeMirror mirror = null;
            try {
                Class<?> builderClass = entity.builder(); // easiest way to get the class TypeMirror
                if (builderClass != void.class) {
                    mirror = elements.getTypeElement(builderClass.getName()).asType();
                }
            } catch (MirroredTypeException typeException) {
                mirror = typeException.getTypeMirror();
            }
            if (mirror != null && mirror.getKind() != TypeKind.VOID) {
                return Optional.of(mirror);
            }
        }
        if (builderFactoryMethod().isPresent()) {
            return Optional.of(builderFactoryMethod().get().getReturnType());
        }
        return ElementFilter.typesIn(element().getEnclosedElements()).stream()
                .filter(element -> element.getSimpleName().toString().contains("Builder"))
                .map(Element::asType)
                .filter(Objects::nonNull)
                .filter(type -> type.getKind() != TypeKind.VOID)
                .findFirst();
    }

    @Override
    public Optional<ExecutableElement> builderFactoryMethod() {
        return ElementFilter.methodsIn(element().getEnclosedElements()).stream()
            .filter(element -> element.getModifiers().contains(Modifier.STATIC))
            .filter(element -> element.getSimpleName().toString().equals("builder"))
            .findFirst();
    }

    @Override
    public Optional<ExecutableElement> factoryMethod() {
        Optional<ExecutableElement> staticFactory =
            ElementFilter.methodsIn(element().getEnclosedElements()).stream()
            .filter(element -> element.getModifiers().contains(Modifier.STATIC))
            .filter(element -> element.getSimpleName().toString().equalsIgnoreCase("create"))
            .filter(element -> element.getParameters().size() > 0)
            .filter(element -> element.getReturnType().equals(element().asType()))
            .findAny();
        Optional<ExecutableElement> constructor =
            ElementFilter.constructorsIn(element().getEnclosedElements()).stream()
            .filter(element -> element.getParameters().size() > 0)
            .findAny();
        return staticFactory.isPresent() ? staticFactory : constructor;
    }

    @Override
    public List<String> factoryArguments() {
        List<String> names = new ArrayList<>();
        ExecutableElement method = factoryMethod().orElseThrow(IllegalStateException::new);
        // TODO need more validation here
        // now match the builder fields to the parameters...
        Map<Element, AttributeDescriptor> map = new LinkedHashMap<>();
        attributes.forEach(attribute -> map.put(attribute.element(), attribute));
        for (VariableElement parameter : method.getParameters()) {
            // straight forward case type and name are the same
            Element matched = null;
            for (Map.Entry<Element, AttributeDescriptor> entry : map.entrySet()) {
                AttributeDescriptor attribute = entry.getValue();
                String fieldName = attribute.fieldName();
                if (fieldName.equalsIgnoreCase(parameter.getSimpleName().toString())) {
                    names.add(fieldName);
                    matched = entry.getKey();
                }
            }
            if (matched != null) {
                map.remove(matched);
            }
        }

        // didn't work likely because the parameter names are missing
        if (names.isEmpty()) {
            // for kotlin data classes add processable element field names in order
            if (isUnimplementable()) {
                ElementFilter.methodsIn(element().getEnclosedElements()).stream()
                        .filter(this::isMethodProcessable)
                        .forEach(getter ->
                                names.addAll(map.entrySet().stream()
                                        .filter(entry -> entry.getKey().equals(getter))
                                        .map(entry -> entry.getValue().fieldName())
                                        .collect(Collectors.toList())));
            } else {
                for (Map.Entry<Element, AttributeDescriptor> entry : map.entrySet()) {
                    names.add(0, entry.getValue().fieldName());
                }
            }
        }
        return names;
    }

    @Override
    public String classFactoryName() {
        // use mirror to avoid loading classes not generated yet
        return Mirrors.findAnnotationMirror(element(), Factory.class)
            .flatMap(Mirrors::findAnnotationValue)
            .map(value -> value.getValue().toString()).orElse(null);
    }
}