/**
 * Copyright 2005-2018 The Kuali Foundation
 *
 * Licensed under the Educational Community 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.opensource.org/licenses/ecl2.php
 *
 * 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.kuali.rice.krad.data.jpa.eclipselink;

import org.apache.commons.lang.StringUtils;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.expressions.Expression;
import org.eclipse.persistence.internal.expressions.FunctionExpression;
import org.eclipse.persistence.internal.helper.DatabaseField;
import org.eclipse.persistence.internal.jpa.metamodel.EmbeddableTypeImpl;
import org.eclipse.persistence.internal.jpa.metamodel.EntityTypeImpl;
import org.eclipse.persistence.internal.jpa.metamodel.ManagedTypeImpl;
import org.eclipse.persistence.internal.jpa.metamodel.PluralAttributeImpl;
import org.eclipse.persistence.internal.jpa.metamodel.SingularAttributeImpl;
import org.eclipse.persistence.jpa.JpaEntityManager;
import org.eclipse.persistence.mappings.AggregateObjectMapping;
import org.eclipse.persistence.mappings.CollectionMapping;
import org.eclipse.persistence.mappings.DatabaseMapping;
import org.eclipse.persistence.mappings.DirectToFieldMapping;
import org.eclipse.persistence.mappings.ForeignReferenceMapping;
import org.eclipse.persistence.mappings.ManyToOneMapping;
import org.eclipse.persistence.mappings.OneToManyMapping;
import org.eclipse.persistence.mappings.OneToOneMapping;
import org.eclipse.persistence.mappings.converters.Converter;
import org.eclipse.persistence.mappings.converters.ConverterClass;
import org.eclipse.persistence.queries.ObjectLevelReadQuery;
import org.kuali.rice.krad.data.jpa.JpaMetadataProviderImpl;
import org.kuali.rice.krad.data.metadata.DataObjectAttributeRelationship;
import org.kuali.rice.krad.data.metadata.DataObjectCollectionSortAttribute;
import org.kuali.rice.krad.data.metadata.DataObjectMetadata;
import org.kuali.rice.krad.data.metadata.DataObjectRelationship;
import org.kuali.rice.krad.data.metadata.MetadataConfigurationException;
import org.kuali.rice.krad.data.metadata.SortDirection;
import org.kuali.rice.krad.data.metadata.impl.DataObjectAttributeImpl;
import org.kuali.rice.krad.data.metadata.impl.DataObjectAttributeRelationshipImpl;
import org.kuali.rice.krad.data.metadata.impl.DataObjectCollectionImpl;
import org.kuali.rice.krad.data.metadata.impl.DataObjectCollectionSortAttributeImpl;
import org.kuali.rice.krad.data.metadata.impl.DataObjectMetadataImpl;
import org.kuali.rice.krad.data.metadata.impl.DataObjectRelationshipImpl;
import org.kuali.rice.krad.data.metadata.impl.MetadataChildBase;

import javax.persistence.metamodel.Attribute.PersistentAttributeType;
import javax.persistence.metamodel.EntityType;
import javax.persistence.metamodel.ManagedType;
import javax.persistence.metamodel.PluralAttribute;
import javax.persistence.metamodel.SingularAttribute;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * Provides an EclipseLink-specific implementation for the {@link JpaMetadataProviderImpl}.
 */
public class EclipseLinkJpaMetadataProviderImpl extends JpaMetadataProviderImpl {
	private static final org.apache.log4j.Logger LOG = org.apache.log4j.Logger
			.getLogger(EclipseLinkJpaMetadataProviderImpl.class);

    /**
     * {@inheritDoc}
     */
	@Override
	protected void populateImplementationSpecificEntityLevelMetadata(DataObjectMetadataImpl metadata,
			EntityType<?> entityType) {
		if ( entityType instanceof EntityTypeImpl ) {
			metadata.setBackingObjectName(((EntityTypeImpl<?>) entityType).getDescriptor().getTableName());
		}
	}

    /**
     * {@inheritDoc}
     */
	@Override
	protected void populateImplementationSpecificAttributeLevelMetadata(DataObjectAttributeImpl attribute,
			SingularAttribute<?, ?> attr) {

		if (attr instanceof SingularAttributeImpl) {
			DatabaseMapping mapping = ((SingularAttributeImpl<?, ?>) attr).getMapping();
			if (mapping != null && mapping.getField() != null) {
                attribute.setReadOnly(mapping.isReadOnly());
				attribute.setBackingObjectName(mapping.getField().getName());
				if (mapping.getField().getLength() != 0) {
					attribute.setMaxLength((long) mapping.getField().getLength());
				}

				// Special check on the converters to attempt to default secure attributes from being shown on the UI
				// We check for a converter which has "encrypt" in its name and auto-set the attribute security
				// to mask the attribute.
				if (mapping instanceof DirectToFieldMapping) {
					Converter converter = ((DirectToFieldMapping) mapping).getConverter();
					// ConverterClass is the internal wrapper EclipseLink uses to wrap the JPA AttributeConverter
					// classes
					// and make them conform to the EclipseLink internal API
					if (converter != null && converter instanceof ConverterClass) {
						// Unfortunately, there is no access to the actual converter class, so we have to hack it
						try {
							Field f = ConverterClass.class.getDeclaredField("attributeConverterClassName");
							f.setAccessible(true);
							String attributeConverterClassName = (String) f.get(converter);
							if (StringUtils.containsIgnoreCase(attributeConverterClassName, "encrypt")) {
								attribute.setSensitive(true);
							}
						} catch (Exception e) {
							LOG.warn("Unable to access the converter name for attribute: "
									+ attribute.getOwningType().getName() + "." + attribute.getName()
									+ "  Skipping attempt to detect converter.");
						}
					}
				}

			}
		}
	}

    /**
     * {@inheritDoc}
     */
	@Override
	protected void populateImplementationSpecificCollectionLevelMetadata(DataObjectCollectionImpl collection,
			PluralAttribute<?, ?, ?> cd) {
		// OJB stores the related class object name. We need to go into the repository and grab the table name.
		Class<?> collectionElementClass = cd.getElementType().getJavaType();
		EntityType<?> elementEntityType = entityManager.getMetamodel().entity(collectionElementClass);
		// get table name behind element
		if (elementEntityType instanceof EntityTypeImpl) {
			collection.setBackingObjectName(((EntityTypeImpl<?>) elementEntityType).getDescriptor().getTableName());
		}

		// Set to read only if store (save) operations should not be pushed through
		PersistentAttributeType persistentAttributeType = cd.getPersistentAttributeType();

		if (cd instanceof PluralAttributeImpl) {
			PluralAttributeImpl<?, ?, ?> coll = (PluralAttributeImpl<?, ?, ?>) cd;
			CollectionMapping collectionMapping = coll.getCollectionMapping();

			if (collectionMapping instanceof OneToManyMapping) {
				OneToManyMapping otm = (OneToManyMapping) collectionMapping;
                populateInverseRelationship(otm, collection);
				Map<DatabaseField, DatabaseField> keyMap = otm.getSourceKeysToTargetForeignKeys();
				List<DataObjectAttributeRelationship> attributeRelationships = new ArrayList<DataObjectAttributeRelationship>();
				for (Map.Entry<DatabaseField, DatabaseField> keyRel : keyMap.entrySet()) {
					attributeRelationships.add(new DataObjectAttributeRelationshipImpl(
							getPropertyNameFromDatabaseColumnName(cd.getDeclaringType(), keyRel.getKey().getName()),
							getPropertyNameFromDatabaseColumnName(elementEntityType, keyRel.getValue().getName())));
				}
				collection.setAttributeRelationships(attributeRelationships);
			}

			collection.setReadOnly(collectionMapping.isReadOnly());
			collection.setSavedWithParent(collectionMapping.isCascadePersist());
			collection.setDeletedWithParent(collectionMapping.isCascadeRemove());
			collection.setLoadedAtParentLoadTime(collectionMapping.isCascadeRefresh() && !collectionMapping.isLazy());
			collection.setLoadedDynamicallyUponUse(collectionMapping.isCascadeRefresh() && collectionMapping.isLazy());
		} else {
			// get what we can based on JPA values (note that we just set some to have values here)
			collection.setReadOnly(false);
			collection.setSavedWithParent(persistentAttributeType == PersistentAttributeType.ONE_TO_MANY);
			collection.setDeletedWithParent(persistentAttributeType == PersistentAttributeType.ONE_TO_MANY);
			collection.setLoadedAtParentLoadTime(true);
			collection.setLoadedDynamicallyUponUse(false);
		}

		// We need to detect the case of a intermediate mapping table. These tables are not directly mapped
		// in OJB, but are referenced by their table and column names.
		// The attributes referenced are assumed to be in the order of the PK fields of the parent and child objects
		// as there is no way to identify the attributes/columns on the linked classes.

		// Extract the default sort order for the collection
		List<DataObjectCollectionSortAttribute> sortAttributes = new ArrayList<DataObjectCollectionSortAttribute>();
		if (cd instanceof PluralAttributeImpl) {
			PluralAttributeImpl<?, ?, ?> coll = (PluralAttributeImpl<?, ?, ?>) cd;
			CollectionMapping collectionMapping = coll.getCollectionMapping();
			if (collectionMapping.getSelectionQuery() instanceof ObjectLevelReadQuery) {
				ObjectLevelReadQuery readQuery = (ObjectLevelReadQuery) collectionMapping.getSelectionQuery();
				List<Expression> orderByExpressions = readQuery.getOrderByExpressions();
				for (Expression expression : orderByExpressions) {
					if (expression instanceof FunctionExpression) {
						String attributeName = ((FunctionExpression) expression).getBaseExpression().getName();
						SortDirection direction = SortDirection.ASCENDING;
						if (expression.getOperator().isOrderOperator()) {
							if (StringUtils
									.containsIgnoreCase(expression.getOperator().getDatabaseStrings()[0], "DESC")) {
								direction = SortDirection.DESCENDING;
							}
						}
						sortAttributes.add(new DataObjectCollectionSortAttributeImpl(attributeName, direction));
					}
				}
			}

		}
		collection.setDefaultCollectionOrderingAttributeNames(sortAttributes);
	}

    /**
     * Returns the property name on the given entity type which the given database column is mapped to.
     *
     * <p>
     * If no field on the given type is mapped to this field (which is common in cases of a JPA relationship without an
     * actual {@link javax.persistence.Column} annotated field to represent the foreign key) then this method will
     * return null.
     * </p>
     *
     * @param entityType the entity type on which to search for a property that is mapped to the given column
     * @param databaseColumnName the name of the database column
     *
     * @return the name of the property on the given entity type which maps to the given column, or null if no such
     *         mapping exists
     */
	@SuppressWarnings({ "unchecked", "rawtypes" })
    protected String getPropertyNameFromDatabaseColumnName(ManagedType entityType, String databaseColumnName) {
		for (SingularAttributeImpl attr : (Set<SingularAttributeImpl>) entityType.getSingularAttributes()) {
			if (!attr.isAssociation()) {
				if (!(attr.getClass().isAssignableFrom(EmbeddableTypeImpl.class)) &&
                        !(attr.getMapping().getClass().isAssignableFrom(AggregateObjectMapping.class)) &&
                        attr.getMapping().getField().getName().equals(databaseColumnName)) {
					return attr.getName();
				}
			}
		}
		return null;
	}

    /**
     * {@inheritDoc}
     */
	@Override
	protected void populateImplementationSpecificRelationshipLevelMetadata(DataObjectRelationshipImpl relationship,
			SingularAttribute<?, ?> rd) {
		// We need to go into the repository and grab the table name.
		Class<?> referencedClass = rd.getBindableJavaType();
		EntityType<?> referencedEntityType = entityManager.getMetamodel().entity(referencedClass);
		if (referencedEntityType instanceof EntityTypeImpl) {
			relationship
					.setBackingObjectName(((EntityTypeImpl<?>) referencedEntityType).getDescriptor().getTableName());
		}
		// Set to read only if store (save) operations should not be pushed through
		PersistentAttributeType persistentAttributeType = rd.getPersistentAttributeType();

		if (rd instanceof SingularAttributeImpl) {
			SingularAttributeImpl<?, ?> rel = (SingularAttributeImpl<?, ?>) rd;

			OneToOneMapping relationshipMapping = (OneToOneMapping) rel.getMapping();
			relationship.setReadOnly(relationshipMapping.isReadOnly());
			relationship.setSavedWithParent(relationshipMapping.isCascadePersist());
			relationship.setDeletedWithParent(relationshipMapping.isCascadeRemove());
			relationship.setLoadedAtParentLoadTime(relationshipMapping.isCascadeRefresh()
					&& !relationshipMapping.isLazy());
			relationship.setLoadedDynamicallyUponUse(relationshipMapping.isCascadeRefresh()
					&& relationshipMapping.isLazy());

            List<DataObjectAttributeRelationship> attributeRelationships = new ArrayList<DataObjectAttributeRelationship>();
            List<String> referencedEntityPkFields = getPrimaryKeyAttributeNames(referencedEntityType);

            for (String referencedEntityPkField : referencedEntityPkFields) {
                for (Map.Entry<DatabaseField, DatabaseField> entry :
                        relationshipMapping.getTargetToSourceKeyFields().entrySet()) {
                    DatabaseField childDatabaseField = entry.getKey();
                    String childFieldName = getPropertyNameFromDatabaseColumnName(referencedEntityType,
                            childDatabaseField.getName());

                    if (referencedEntityPkField.equalsIgnoreCase(childFieldName)) {
                        DatabaseField parentDatabaseField = entry.getValue();
                        String parentFieldName = getPropertyNameFromDatabaseColumnName(rd.getDeclaringType(),
                                parentDatabaseField.getName());

                        if (parentFieldName != null) {
                            attributeRelationships
                                    .add(new DataObjectAttributeRelationshipImpl(parentFieldName, childFieldName));
                            break;
                        } else {
                            LOG.warn("Unable to find parent field reference.  There may be a JPA mapping problem on " +
                                    referencedEntityType.getJavaType() + ": " + relationship);
                        }
                    }
                }
            }

            relationship.setAttributeRelationships(attributeRelationships);

            populateInverseRelationship(relationshipMapping, relationship);

		} else {
			// get what we can based on JPA values (note that we just set some to have values here)
			relationship.setReadOnly(persistentAttributeType == PersistentAttributeType.MANY_TO_ONE);
			relationship.setSavedWithParent(persistentAttributeType == PersistentAttributeType.ONE_TO_ONE);
			relationship.setDeletedWithParent(persistentAttributeType == PersistentAttributeType.ONE_TO_ONE);
			relationship.setLoadedAtParentLoadTime(true);
			relationship.setLoadedDynamicallyUponUse(false);
		}
	}

    /**
     * Populates the inverse relationship for a given relationship.
     *
     * @param mapping the {@link DatabaseMapping} that defines the relationship.
     * @param relationship the relationship of which to populate the other side.
     */
    protected void populateInverseRelationship(DatabaseMapping mapping, MetadataChildBase relationship) {
        DatabaseMapping relationshipPartner = findRelationshipPartner(mapping);
        if (relationshipPartner != null) {
            Class<?> partnerType = relationshipPartner.getDescriptor().getJavaClass();
            DataObjectMetadata partnerMetadata = masterMetadataMap.get(partnerType);
            // if the target metadata is not null, it means that entity has already been processed,
            // so we can go ahead and establish the inverse relationship
            if (partnerMetadata != null) {
                // first check if it's a relationship
                MetadataChildBase relationshipPartnerMetadata =
                        (MetadataChildBase)partnerMetadata.getRelationship(relationshipPartner.getAttributeName());
                if (relationshipPartnerMetadata == null) {
                    relationshipPartnerMetadata =
                            (MetadataChildBase)partnerMetadata.getCollection(relationshipPartner.getAttributeName());
                }
                if (relationshipPartnerMetadata != null) {
                    relationshipPartnerMetadata.setInverseRelationship(relationship);
                    relationship.setInverseRelationship(relationshipPartnerMetadata);
                }

            }
        }
    }

    /**
     * Gets the inverse mapping of the given {@link DatabaseMapping}.
     *
     * @param databaseMapping the {@link DatabaseMapping} of which to get the inverse.
     * @return the inverse mapping of the given {@link DatabaseMapping}.
     */
    protected DatabaseMapping findRelationshipPartner(DatabaseMapping databaseMapping) {
        if (databaseMapping instanceof OneToManyMapping) {
            OneToManyMapping mapping = (OneToManyMapping)databaseMapping;
            if (mapping.getMappedBy() != null) {
                Class<?> referenceClass = mapping.getReferenceClass();
                ClassDescriptor referenceClassDescriptor = getClassDescriptor(referenceClass);
                return referenceClassDescriptor.getMappingForAttributeName(mapping.getMappedBy());
            }
        } else if (databaseMapping instanceof ManyToOneMapping) {
            // one odd thing just to note here, for ManyToOne mappings with an inverse OneToMany, for some reason the
            // getMappedBy method still returns the mappedBy from the OneToMany side, so we can't use nullness of
            // mappedBy to infer which side of the relationship we are on, oddly enough, that's not the way it works
            // for OneToOne mappings (see below)...go figure
            //
            // I have to assume this is some sort of bug in EclipseLink metadata
            ManyToOneMapping mapping = (ManyToOneMapping)databaseMapping;
            Class<?> referenceClass = mapping.getReferenceClass();
            ClassDescriptor referenceClassDescriptor = getClassDescriptor(referenceClass);
            // find the OneToMany mapping which points back to this ManyToOne
            for (DatabaseMapping referenceMapping : referenceClassDescriptor.getMappings()) {
                if (referenceMapping instanceof OneToManyMapping) {
                    OneToManyMapping oneToManyMapping = (OneToManyMapping)referenceMapping;
                    if (mapping.getAttributeName().equals(oneToManyMapping.getMappedBy())) {
                        return oneToManyMapping;
                    }
                }
            }
        } else if (databaseMapping instanceof OneToOneMapping) {
            OneToOneMapping mapping = (OneToOneMapping)databaseMapping;
            // well for reasons I can't quite fathom, mappedBy is always null on OneToOne relationships,
            // thankfully it's OneToOne so it's pretty easy to figure out the inverse
            ClassDescriptor referenceClassDescriptor = getClassDescriptor(mapping.getReferenceClass());
            // let's check if theres a OneToOne pointing back to us
            for (DatabaseMapping referenceMapping : referenceClassDescriptor.getMappings()) {
                if (referenceMapping instanceof OneToOneMapping) {
                    OneToOneMapping oneToOneMapping = (OneToOneMapping)referenceMapping;
                    if (oneToOneMapping.getReferenceClass().equals(mapping.getDescriptor().getJavaClass())) {
                        return oneToOneMapping;
                    }
                }
            }
        }
        // TODO need to implement for bi-directional OneToOne and ManyToMany
        return null;
    }

    /**
     * {@inheritDoc}
     */
	@Override
	public DataObjectRelationship addExtensionRelationship(Class<?> entityClass, String extensionPropertyName,
			Class<?> extensionEntityClass) {
		ClassDescriptor entityDescriptor = getClassDescriptor(entityClass);
		ClassDescriptor extensionEntityDescriptor = getClassDescriptor(extensionEntityClass);

		if (LOG.isDebugEnabled()) {
			LOG.debug("About to attempt to inject a 1:1 relationship on PKs between " + entityDescriptor + " and "
					+ extensionEntityDescriptor);
		}
		OneToOneMapping dm = (OneToOneMapping) entityDescriptor.newOneToOneMapping();
		dm.setAttributeName(extensionPropertyName);
		dm.setReferenceClass(extensionEntityClass);
		dm.setDescriptor(entityDescriptor);
		dm.setIsPrivateOwned(true);
		dm.setJoinFetch(ForeignReferenceMapping.OUTER_JOIN);
		dm.setCascadeAll(true);
		dm.setIsLazy(false);
		dm.dontUseIndirection();
		dm.setIsOneToOneRelationship(true);
		dm.setRequiresTransientWeavedFields(false);

        OneToOneMapping inverse = findExtensionInverse(extensionEntityDescriptor, entityClass);
        dm.setMappedBy(inverse.getAttributeName());
        for (DatabaseField sourceField : inverse.getSourceToTargetKeyFields().keySet()) {
            DatabaseField targetField = inverse.getSourceToTargetKeyFields().get(sourceField);
            // reverse them, pass the source from the inverse as our target and the target from the inverse as our source
            dm.addTargetForeignKeyField(sourceField, targetField);
        }

        dm.preInitialize(getEclipseLinkEntityManager().getDatabaseSession());
		dm.initialize(getEclipseLinkEntityManager().getDatabaseSession());
		entityDescriptor.addMapping(dm);
		entityDescriptor.getObjectBuilder().initialize(getEclipseLinkEntityManager().getDatabaseSession());

        // build the data object relationship
        ManagedTypeImpl<?> managedType = (ManagedTypeImpl<?>)getEntityManager().getMetamodel().managedType(entityClass);
        SingularAttributeImpl<?, ?> singularAttribute = new SingularAttributeLocal(managedType, dm);
        return getRelationshipMetadata(singularAttribute);
	}

    /**
     * Provides a local implementation of {@link SingularAttributeImpl}.
     */
    class SingularAttributeLocal extends SingularAttributeImpl {

        /**
         * Creates a local implementation of {@link SingularAttributeImpl}.
         *
         * @param managedType the {@link ManagedType}.
         * @param mapping the {@link DatabaseMapping}.
         */
        SingularAttributeLocal(ManagedTypeImpl managedType, DatabaseMapping mapping) {
            super(managedType, mapping);
        }
    }

    /**
     * Gets the inverse extension of the given {@link ClassDescriptor}.
     *
     * @param extensionEntityDescriptor the {@link ClassDescriptor} of which to get the inverse.
     * @param entityType the type of the entity.
     * @return the inverse extension of the given {@link ClassDescriptor}.
     */
    protected OneToOneMapping findExtensionInverse(ClassDescriptor extensionEntityDescriptor, Class<?> entityType) {
        Collection<DatabaseMapping> derivedIdMappings = extensionEntityDescriptor.getDerivesIdMappinps();
        String extensionInfo = "(" + extensionEntityDescriptor.getJavaClass().getName() + " -> " + entityType.getName()
                + ")";
        if (derivedIdMappings == null || derivedIdMappings.isEmpty()) {
            throw new MetadataConfigurationException("Attempting to use extension framework, but extension "
                    + extensionInfo + " does not have a valid inverse OneToOne Id mapping back to the extended data "
                    + "object. Please ensure it is annotated property for use of the extension framework with JPA.");
        } else if (derivedIdMappings.size() > 1) {
            throw new MetadataConfigurationException("When attempting to determine the inverse relationship for use "
                    + "with extension framework " + extensionInfo + " encountered more than one 'derived id' mapping, "
                    + "there should be only one!");
        }
        DatabaseMapping inverseMapping = derivedIdMappings.iterator().next();
        if (!(inverseMapping instanceof OneToOneMapping)) {
            throw new MetadataConfigurationException("Identified an inverse derived id mapping for extension "
                    + "relationship " + extensionInfo + " but it was not a one-to-one mapping: " + inverseMapping);
        }
        return (OneToOneMapping)inverseMapping;
    }

    /**
     * Gets the descriptor for the entity type.
     *
     * @param entityClass the type of the enty.
     * @return the descriptor for the entity type.
     */
    protected ClassDescriptor getClassDescriptor(Class<?> entityClass) {
		return getEclipseLinkEntityManager().getDatabaseSession().getDescriptor(entityClass);
	}

    /**
     * The entity manager for interacting with the database.
     * @return the entity manager for interacting with the database.
     */
	protected JpaEntityManager getEclipseLinkEntityManager() {
		return (JpaEntityManager) entityManager;
	}
}
