/*****************************************************************
 *   Licensed to the Apache Software Foundation (ASF) under one
 *  or more contributor license agreements.  See the NOTICE file
 *  distributed with this work for additional information
 *  regarding copyright ownership.  The ASF licenses this file
 *  to you 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 org.apache.cayenne.jpa.conf;

import java.lang.annotation.Annotation;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.Map;

import javax.persistence.AssociationOverride;
import javax.persistence.AssociationOverrides;
import javax.persistence.AttributeOverride;
import javax.persistence.AttributeOverrides;
import javax.persistence.Basic;
import javax.persistence.Column;
import javax.persistence.Embeddable;
import javax.persistence.Embedded;
import javax.persistence.EmbeddedId;
import javax.persistence.Entity;
import javax.persistence.EntityListeners;
import javax.persistence.Enumerated;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Lob;
import javax.persistence.ManyToMany;
import javax.persistence.ManyToOne;
import javax.persistence.MapKey;
import javax.persistence.MappedSuperclass;
import javax.persistence.NamedNativeQueries;
import javax.persistence.NamedNativeQuery;
import javax.persistence.NamedQueries;
import javax.persistence.NamedQuery;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;
import javax.persistence.OrderBy;
import javax.persistence.SequenceGenerator;
import javax.persistence.SqlResultSetMapping;
import javax.persistence.TableGenerator;
import javax.persistence.Temporal;
import javax.persistence.Transient;
import javax.persistence.Version;

import org.apache.cayenne.jpa.JpaProviderException;
import org.apache.cayenne.jpa.map.AccessType;
import org.apache.cayenne.jpa.map.JpaAbstractEntity;
import org.apache.cayenne.jpa.map.JpaAttribute;
import org.apache.cayenne.jpa.map.JpaClassDescriptor;
import org.apache.cayenne.jpa.map.JpaManagedClass;
import org.apache.cayenne.jpa.map.JpaPropertyDescriptor;
import org.apache.cayenne.util.Util;
import org.apache.cayenne.validation.SimpleValidationFailure;

/**
 * {@link org.apache.cayenne.jpa.map.JpaEntityMap} loader that reads mapping information
 * from the class annotations per JPA specification.
 * <h3>Specification Documentation, persistence_1_0.xsd, "class" element.</h3>
 * <p>
 * [Each managed class] should be annotated with either \@Entity, \@Embeddable or
 * \@MappedSuperclass
 * 
 * @author Andrus Adamchik
 */
public class EntityMapAnnotationLoader {

    static final Map<String, Integer> TYPE_ANNOTATION_ORDERING_WEIGHTS;
    static final Map<String, Integer> MEMBER_ANNOTATION_ORDERING_WEIGHTS;

    static {

        TYPE_ANNOTATION_ORDERING_WEIGHTS = new HashMap<String, Integer>();

        // annotations that are top-level only
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(Entity.class.getName(), 1);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(Embeddable.class.getName(), 1);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(MappedSuperclass.class.getName(), 1);

        // annotations that can be a part of Entity or EntityMap
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(SequenceGenerator.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(NamedNativeQueries.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(NamedNativeQuery.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(NamedQueries.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(NamedQuery.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(SqlResultSetMapping.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(TableGenerator.class.getName(), 2);
        TYPE_ANNOTATION_ORDERING_WEIGHTS.put(EntityListeners.class.getName(), 2);

        MEMBER_ANNOTATION_ORDERING_WEIGHTS = new HashMap<String, Integer>();

        // first level of member annotations - annotations representing different types of
        // attributes
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Id.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Basic.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(EmbeddedId.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Version.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(ManyToOne.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(OneToMany.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(OneToOne.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(ManyToMany.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Embedded.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Transient.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(AssociationOverride.class.getName(), 1);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(AssociationOverrides.class.getName(), 1);

        // second level - attribute overrides (can belong to Embedded or can be a part of
        // the entity )
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(AttributeOverride.class.getName(), 2);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(AttributeOverrides.class.getName(), 2);

        // third level of member annotations - details implying one of the attributes
        // above
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(GeneratedValue.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Temporal.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(TableGenerator.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(SequenceGenerator.class.getName(), 3);

        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Lob.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Temporal.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Enumerated.class.getName(), 3);

        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(MapKey.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(OrderBy.class.getName(), 3);
        MEMBER_ANNOTATION_ORDERING_WEIGHTS.put(Column.class.getName(), 3);
    }

    protected EntityMapLoaderContext context;

    protected Comparator<Annotation> typeAnnotationsSorter;
    protected Comparator<Annotation> memberAnnotationsSorter;

    protected AnnotationProcessorFactory classProcessorFactory;
    protected AnnotationProcessorFactory memberProcessorFactory;
    protected AnnotationProcessorFactory callbackProcessorFactory;

    public EntityMapAnnotationLoader(EntityMapLoaderContext context) {
        this.context = context;
        this.typeAnnotationsSorter = new AnnotationSorter(
                TYPE_ANNOTATION_ORDERING_WEIGHTS);
        this.memberAnnotationsSorter = new AnnotationSorter(
                MEMBER_ANNOTATION_ORDERING_WEIGHTS);

        this.classProcessorFactory = new ClassAnnotationProcessorFactory();
        this.memberProcessorFactory = new MemberAnnotationProcessorFactory();
        this.callbackProcessorFactory = new EntityCallbackAnnotationProcessorFactory();
    }

    /**
     * Processes annotations of a single Java class, loading ORM mapping information to
     * the provided entity map.
     */
    public void loadClassMapping(Class managedClass) throws JpaProviderException {

        // avoid duplicates loaded from annotations per CAY-756
        if (context.getEntityMap().containsManagedClass(managedClass.getName())) {
            context.recordConflict(new SimpleValidationFailure(
                    managedClass.getName(),
                    "Duplicate managed class declaration " + managedClass.getName()));
            return;
        }

        Annotation[] classAnnotations = managedClass.getAnnotations();

        // per 'getAnnotations' docs, array is returned by copy, so we can modify it...
        Arrays.sort(classAnnotations, typeAnnotationsSorter);

        JpaClassDescriptor descriptor = new JpaClassDescriptor(managedClass);

        // initially set access to the map level access - may be overriden below
        descriptor.setAccess(context.getEntityMap().getAccess());

        AnnotationContext stack = new AnnotationContext(descriptor);
        stack.push(context.getEntityMap());

        // === push class-level stuff
        for (int i = 0; i < classAnnotations.length; i++) {
            AnnotationProcessor processor = classProcessorFactory
                    .getProcessor(classAnnotations[i]);
            if (processor != null) {
                processor.onStartElement(managedClass, stack);
            }
        }

        // if class is not properly annotated, bail early
        if (stack.depth() == 1) {
            return;
        }

        // apply entity callbacks ...
        if (stack.peek() instanceof JpaAbstractEntity) {
            for (Method callback : getEntityCallbacks(managedClass)) {
                applyEntityCallbackAnnotations(callback, stack);
            }
        }

        // per JPA spec, 2.1.1, regarding access type:

        // When annotations are used, the placement of the mapping annotations on either
        // the persistent fields or persistent properties of the entity class specifies
        // the access type as being either field- or property-based access respectively.

        // Question (andrus) - if no annotations are placed at the field or method level,
        // we still must determine the access type to apply default mappping rules. How?
        // (using FIELD access for now).

        boolean fieldAccess = false;

        for (JpaPropertyDescriptor property : descriptor.getFieldDescriptors()) {
            stack.setPropertyDescriptor(property);
            if (applyMemberAnnotations(property, stack)) {
                fieldAccess = true;
            }
        }

        boolean propertyAccess = false;

        for (JpaPropertyDescriptor property : descriptor.getPropertyDescriptors()) {
            stack.setPropertyDescriptor(property);
            if (applyMemberAnnotations(property, stack)) {
                propertyAccess = true;
            }
        }

        if (stack.peek() instanceof JpaManagedClass) {
            JpaManagedClass entity = (JpaManagedClass) stack.peek();
            // sanity check
            if (fieldAccess && propertyAccess) {
                throw new JpaProviderException("Entity '"
                        + entity.getClassName()
                        + "' has both property and field annotations.");
            }

            // TODO: andrus - 11/29/2006 - clean this redundancy - access field should be
            // stored either in the entity or the descriptor.
            if (fieldAccess) {
                descriptor.setAccess(AccessType.FIELD);
                entity.setAccess(AccessType.FIELD);
            }
            else if (propertyAccess) {
                descriptor.setAccess(AccessType.PROPERTY);
                entity.setAccess(AccessType.PROPERTY);
            }
        }

        // === pop class-level stuff
        for (int i = classAnnotations.length - 1; i >= 0; i--) {
            AnnotationProcessor processor = classProcessorFactory
                    .getProcessor(classAnnotations[i]);
            if (processor != null) {
                processor.onFinishElement(managedClass, stack);
            }
        }
    }

    /**
     * Processes member annotations, returning true if at least one JPA annotation was
     * found.
     */
    protected boolean applyMemberAnnotations(
            JpaPropertyDescriptor property,
            AnnotationProcessorStack stack) {

        AnnotatedElement member = property.getMember();

        Annotation[] annotations = member.getAnnotations();
        // per 'getAnnotations' docs, array is returned by copy, so we can modify it...
        Arrays.sort(annotations, memberAnnotationsSorter);

        for (int j = 0; j < annotations.length; j++) {

            AnnotationProcessor memberProcessor = memberProcessorFactory
                    .getProcessor(annotations[j]);

            if (memberProcessor != null) {
                memberProcessor.onStartElement(member, stack);
            }
        }

        for (int j = annotations.length - 1; j >= 0; j--) {

            AnnotationProcessor memberProcessor = memberProcessorFactory
                    .getProcessor(annotations[j]);

            if (memberProcessor != null) {
                memberProcessor.onFinishElement(member, stack);
            }
        }

        return annotations.length > 0;
    }

    protected void applyEntityCallbackAnnotations(
            Method method,
            AnnotationProcessorStack stack) {

        Annotation[] annotations = method.getAnnotations();

        for (int j = 0; j < annotations.length; j++) {

            AnnotationProcessor callbackProcessor = callbackProcessorFactory
                    .getProcessor(annotations[j]);

            if (callbackProcessor != null) {
                callbackProcessor.onStartElement(method, stack);
            }
        }

        // don't call 'onFinishElement' as there is no nesting within callback
        // annotations...
    }

    /**
     * Returns a collection of methods that match an 'entity callback" pattern, i.e. "void
     * <METHOD>()".
     */
    protected Collection<Method> getEntityCallbacks(Class managedClass) {

        Collection<Method> callbacks = new ArrayList<Method>(3);

        Method[] methods = managedClass.getDeclaredMethods();
        for (int i = 0; i < methods.length; i++) {

            int modifiers = methods[i].getModifiers();
            if (Modifier.isStatic(modifiers) || Modifier.isFinal(modifiers)) {
                continue;
            }

            if (!Void.TYPE.equals(methods[i].getReturnType())) {
                continue;
            }

            Class[] params = methods[i].getParameterTypes();
            if (params.length != 0) {
                continue;
            }

            callbacks.add(methods[i]);
        }

        return callbacks;
    }

    /**
     * Comparator for TYPE level JPA annotations that first returns top-level annotations
     * that define what kind of managed persistent class is being annotated.
     */
    final class AnnotationSorter implements Comparator<Annotation> {

        private Map<String, Integer> weights;

        AnnotationSorter(Map<String, Integer> weights) {
            this.weights = weights;
        }

        public int compare(Annotation o1, Annotation o2) {
            Integer w1 = weights.get(o1.annotationType().getName());
            Integer w2 = weights.get(o2.annotationType().getName());

            // nulls go last as all non-top annotations are not explicitly mentioned
            // mapped to sorting weight
            return Util.nullSafeCompare(false, w1, w2);
        }
    }

    final class AnnotationContext implements AnnotationProcessorStack {

        LinkedList stack = new LinkedList();
        JpaClassDescriptor classDescriptor;
        JpaPropertyDescriptor propertyDescriptor;

        AnnotationContext(JpaClassDescriptor classDescriptor) {
            this.classDescriptor = classDescriptor;
        }

        void setPropertyDescriptor(JpaPropertyDescriptor propertyDescriptor) {
            this.propertyDescriptor = propertyDescriptor;
        }

        public int depth() {
            return stack.size();
        }

        public Object peek() {
            return stack.peek();
        }

        public Object pop() {
            return stack.removeFirst();
        }

        public void push(Object object) {

            // do descriptor injection...
            if (object instanceof JpaAttribute) {
                JpaAttribute attribute = (JpaAttribute) object;
                attribute.setName(propertyDescriptor.getName());
                attribute.setPropertyDescriptor(propertyDescriptor);
            }
            else if (object instanceof JpaManagedClass) {
                ((JpaManagedClass) object).setClassDescriptor(classDescriptor);
            }

            stack.addFirst(object);
        }

        public void recordConflict(
                AnnotatedElement element,
                Class annotatedType,
                String message) {

            StringBuilder buffer = new StringBuilder();
            buffer.append("Problem processing annotation: ").append(
                    annotatedType.getName());
            buffer.append(", annotated element: ").append(element);

            if (message != null) {
                buffer.append(", details: ").append(message);
            }

            context
                    .recordConflict(new SimpleValidationFailure(peek(), buffer.toString()));
        }
    }
}
