package com.breeze.hib;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.hibernate.EntityMode;
import org.hibernate.LockOptions;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.metadata.ClassMetadata;
import org.hibernate.persister.entity.AbstractEntityPersister;
import org.hibernate.type.ComponentType;
import org.hibernate.type.EntityType;
import org.hibernate.type.ForeignKeyDirection;
import org.hibernate.type.Type;

import com.breeze.save.EntityInfo;
import com.breeze.save.EntityState;
import com.breeze.save.SaveWorkState;


/**
 * Utility class for re-establishing the relationships between entities prior to saving them in Hibernate.
 * Breeze requires many-to-one relationships to have properties both the related entity and its ID, and it 
 * sends only the ID in the save bundle.  To make it work with Hibernate, we map the <code>many-to-one</code> entity, and map the 
 * foreign key ID with <code> insert="false" update="false" </code>, so the <code>many-to-one</code> entity must
 * be populated in order for the foreign key value to be saved in the DB.  To work
 * around this problem, this class uses the IDs sent by Breeze to re-connect the related entities.
 * @author Steve
 */
class RelationshipFixer {
    private SaveWorkState saveWorkState;
    private Map<String, String> fkMap;
    private Session session;
    private SessionFactory sessionFactory;
    private List<EntityInfo> saveOrder;
    private List<EntityInfo> deleteOrder;
    // map of EntityInfo -> list of parent EntityInfo
    private Map<EntityInfo, List<EntityInfo>> dependencyGraph;
    private boolean removeMode;

    /**
     * Create new instance with the given saveMap and fkMap.  Since the saveMap is unique per save, 
     * this instance will be useful for processing one entire save bundle only.
     * @param saveMap Map of entity types -> entity instances to save.  This is provided by Breeze in the SaveChanges call.
     * @param fkMap Map of relationship name -> foreign key name.  This is built in the MetadataBuilder class.
     * @param session Hibernate session that will save the entities
     */
    public RelationshipFixer(SaveWorkState saveWorkState, Session session) {
        super();
        this.saveWorkState = saveWorkState;
        this.fkMap = saveWorkState.getMetadata().getRawMetadata().foreignKeyMap;
        this.session = session;
        this.sessionFactory = session.getSessionFactory();
        this.dependencyGraph = new HashMap<EntityInfo, List<EntityInfo>>();
    }

    /**
     * Connect the related entities in the saveMap to other entities.  If the related entities
     * are not in the saveMap, they are loaded from the session.
     * @return The list of entities in the order they should be saved, according to their relationships.
     */
    public void fixupRelationships() {
        this.removeMode = false;
        processRelationships();
    }

    /**
     * Remove the navigations between entities in the saveMap.
     * This flattens the JSON result so Breeze can handle it.
     */
    public void removeRelationships() {
        this.removeMode = true;
        processRelationships();
    }
    
    /**
     * Sort the entries in the dependency graph according to their dependencies.
     * @return the sorted list
     */
    public List<EntityInfo> sortDependencies() {
        saveOrder = new ArrayList<EntityInfo>();
        deleteOrder = new ArrayList<EntityInfo>();
        for (EntityInfo entityInfo : dependencyGraph.keySet()) {
            addToSaveOrder(entityInfo, 0);
        }
        Collections.reverse(deleteOrder);
        saveOrder.addAll(deleteOrder);
        return saveOrder;
    }
    
    public void processRelationships(EntityInfo entityInfo, boolean removeMode) {
        this.removeMode = removeMode;
        Class entityType = entityInfo.entity.getClass();
        ClassMetadata classMeta = sessionFactory.getClassMetadata(entityType);
        processRelationships(entityInfo, classMeta);
    }

    /**
     * Add the relationship to the dependencyGraph
     * @param child Entity that depends on parent (e.g. has a many-to-one relationship to parent)
     * @param parent Entity that child depends on (e.g. one parent has one-to-many children)
     */
    private void addToGraph(EntityInfo child, EntityInfo parent) {
        List<EntityInfo> list = dependencyGraph.get(child);
        if (list == null) {
            list = new ArrayList<EntityInfo>(5);
            dependencyGraph.put(child, list);
        }
        if (parent != null) list.add(parent);

        //        if (removeReverse) {
        //            List<EntityInfo> parentList = dependencyGraph.get(parent);
        //            if (parentList != null) {
        //                parentList.remove(child);
        //            }
        //        }
    }

    /**
     * Recursively add entities to the saveOrder or deleteOrder according to their dependencies
     * @param entityInfo Entity to be added.  Its dependencies will be added depth-first.
     * @param depth prevents infinite recursion in case of cyclic dependencies
     */
    private void addToSaveOrder(EntityInfo entityInfo, int depth) {
        if (saveOrder.contains(entityInfo)) return;
        if (deleteOrder.contains(entityInfo)) return;
        if (depth > 10) return;

        List<EntityInfo> dependencies = dependencyGraph.get(entityInfo);
        for (EntityInfo dep : dependencies) {
            addToSaveOrder(dep, depth + 1);
        }

        if (entityInfo.entityState == EntityState.Deleted) deleteOrder.add(entityInfo);
        else saveOrder.add(entityInfo);
    }

    /**
     * Add or remove the entity relationships according to the current removeMode.
     */
    private void processRelationships() {
        for (Entry<Class, List<EntityInfo>> entry : saveWorkState.entrySet()) {

            Class entityType = entry.getKey();
            ClassMetadata classMeta = sessionFactory.getClassMetadata(entityType);

            for (EntityInfo entityInfo : entry.getValue()) {
                processRelationships(entityInfo, classMeta);
            }
        }
    }
    
    

    /**
     * Connect the related entities based on the foreign key values.
     * Note that this may cause related entities to be loaded from the DB if they are not already in the session.
     * @param entityInfo Entity that will be saved
     * @param meta Metadata about the entity type
     */
    private void processRelationships(EntityInfo entityInfo, ClassMetadata meta) {
        addToGraph(entityInfo, null); // make sure every entity is in the graph
        String[] propNames = meta.getPropertyNames();
        Type[] propTypes = meta.getPropertyTypes();

        Type propType = meta.getIdentifierType();
        if (propType != null) {
            processRelationship(meta.getIdentifierPropertyName(), propType, entityInfo, meta);
        }

        for (int i = 0; i < propNames.length; i++) {
            processRelationship(propNames[i], propTypes[i], entityInfo, meta);
        }
    }

    /**
     * Handle a specific property if it is a Association or Component relationship.
     * @param propName
     * @param propType
     * @param entityInfo
     * @param meta
     */
    private void processRelationship(String propName, Type propType, EntityInfo entityInfo, ClassMetadata meta) {
        if (propType.isAssociationType() && propType.isEntityType()) {
            fixupRelationship(propName, (EntityType) propType, entityInfo, meta);
        }
        else if (propType.isComponentType()) {
            fixupComponentRelationships(propName, (ComponentType) propType, entityInfo, meta);
        }
    }

    /**
     * Connect the related entities based on the foreign key values found in a component type.
     * This updates the values of the component's properties.
     * @param propName Name of the (component) property of the entity.  May be null if the property is the entity's identifier.
     * @param compType Type of the component
     * @param entityInfo Breeze EntityInfo
     * @param meta Metadata for the entity class
     */
    private void fixupComponentRelationships(String propName, ComponentType compType, EntityInfo entityInfo, ClassMetadata meta) {
        String[] compPropNames = compType.getPropertyNames();
        Type[] compPropTypes = compType.getSubtypes();
        Object component = null;
        Object[] compValues = null;
        boolean isChanged = false;
        for (int j = 0; j < compPropNames.length; j++) {
            Type compPropType = compPropTypes[j];
            if (compPropType.isAssociationType() && compPropType.isEntityType())  {
                if (compValues == null) {
                    // get the value of the component's subproperties
                    component = getPropertyValue(meta, entityInfo.entity, propName);
                    compValues = compType.getPropertyValues(component, EntityMode.POJO);
                }
                if (compValues[j] == null) {
                    // the related entity is null
                    Object relatedEntity = getRelatedEntity(compPropNames[j], (EntityType) compPropType, entityInfo, meta);
                    if (relatedEntity != null)  {
                        compValues[j] = relatedEntity;
                        isChanged = true;
                    }
                } else if (removeMode) {
                    // remove the relationship
                    compValues[j] = null;
                    isChanged = true;
                }
            }
        }
        if (isChanged) {
            compType.setPropertyValues(component, compValues, EntityMode.POJO);
        }
    }

    /**
     * Set an association value based on the value of the foreign key.  This updates the property of the entity.
     * @param propName Name of the navigation/association property of the entity, e.g. "Customer".  May be null if the property is the entity's identifier.
     * @param propType Type of the property
     * @param entityInfo Breeze EntityInfo
     * @param meta Metadata for the entity class
     */
    private void fixupRelationship(String propName, EntityType propType, EntityInfo entityInfo, ClassMetadata meta)
    {
        Object entity = entityInfo.entity;
        if (removeMode) {
            meta.setPropertyValue(entity, propName, null);
            return;
        }
        Object relatedEntity = getPropertyValue(meta, entity, propName);
        if (relatedEntity != null) {
            // entities are already connected - still need to add to dependency graph
            EntityInfo relatedEntityInfo = saveWorkState.findEntityInfo(relatedEntity);
            maybeAddToGraph(entityInfo, relatedEntityInfo, propType); 
            return;
        }

        relatedEntity = getRelatedEntity(propName, propType, entityInfo, meta);

        if (relatedEntity != null) {
            meta.setPropertyValue(entity, propName, relatedEntity);
        }
    }

    /**
     * Get a related entity based on the value of the foreign key.  Attempts to find the related entity in the
     * saveMap; if its not found there, it is loaded via the Session (which should create a proxy, not actually load
     * the entity from the database).
     * Related entities are Promoted in the saveOrder according to their state.
     * @param propName Name of the navigation/association property of the entity, e.g. "Customer".  May be null if the property is the entity's identifier.
     * @param propType Type of the property
     * @param entityInfo Breeze EntityInfo
     * @param meta Metadata for the entity class
     * @return
     */
    private Object getRelatedEntity(String propName, EntityType propType, EntityInfo entityInfo, ClassMetadata meta) {
        Object relatedEntity = null;
        String foreignKeyName = findForeignKey(propName, meta);
        Object id = getForeignKeyValue(entityInfo, meta, foreignKeyName);

        if (id != null) {
            Class returnEntityClass = propType.getReturnedClass();
            EntityInfo relatedEntityInfo = saveWorkState.findEntityInfoById(returnEntityClass, id);

            if (relatedEntityInfo == null) {
                EntityState state = entityInfo.entityState;
                //            	if (state == EntityState.Added || state == EntityState.Modified || (state == EntityState.Deleted 
                //            			&& propType.getForeignKeyDirection() != ForeignKeyDirection.FOREIGN_KEY_TO_PARENT)) {
                if (state != EntityState.Deleted || propType.getForeignKeyDirection() != ForeignKeyDirection.FOREIGN_KEY_TO_PARENT) {
                    String relatedEntityName = propType.getName();
                    relatedEntity = session.load(relatedEntityName, (Serializable) id, LockOptions.NONE);
                }
            } else {
                maybeAddToGraph(entityInfo, relatedEntityInfo, propType);
                relatedEntity = relatedEntityInfo.entity;
            }
        }
        return relatedEntity;
    }

    /** Add the parent-child relationship for certain propType conditions */
    private void maybeAddToGraph(EntityInfo child, EntityInfo parent, EntityType propType) {
        if (!(propType.isOneToOne() && propType.useLHSPrimaryKey() && (propType.getForeignKeyDirection() == ForeignKeyDirection.FOREIGN_KEY_TO_PARENT))) {
            addToGraph(child, parent);
        }
    }

    /**
     * Find a foreign key matching the given property, by looking in the fkMap.
     * The property may be defined on the class or a superclass, so this function calls itself recursively.
     * @param propName Name of the property e.g. "Product"
     * @param meta Class metadata, for traversing the class hierarchy
     * @return The name of the foreign key, e.g. "ProductID"
     */
    private String findForeignKey(String propName, ClassMetadata meta) {
        String relKey = meta.getEntityName() + '.' + propName;
        if (fkMap.containsKey(relKey)) {
            return fkMap.get(relKey);
        } else if (meta.isInherited() && meta instanceof AbstractEntityPersister) {
            String superEntityName = ((AbstractEntityPersister) meta).getMappedSuperclass();
            ClassMetadata superMeta = sessionFactory.getClassMetadata(superEntityName);
            return findForeignKey(propName, superMeta);
        } else {
            throw new IllegalArgumentException("Foreign Key '" + relKey + "' could not be found.");
        }
    }

    /**
     * Get the value of the foreign key property.  This comes from the entity, but if that value is
     * null, and the entity is deleted, we try to get it from the originalValuesMap.
     * @param entityInfo Breeze EntityInfo
     * @param meta Metadata for the entity class
     * @param foreignKeyName Name of the foreign key property of the entity, e.g. "CustomerID"
     * @return
     */
    private Object getForeignKeyValue(EntityInfo entityInfo, ClassMetadata meta, String foreignKeyName) {
        Object entity = entityInfo.entity;
        Object id = null;
        if (foreignKeyName.equalsIgnoreCase(meta.getIdentifierPropertyName())) {
            id = meta.getIdentifier(entity, null);
        } else if (Arrays.asList(meta.getPropertyNames()).contains(foreignKeyName)) {
            id = meta.getPropertyValue(entity, foreignKeyName);
        } else if (meta.getIdentifierType().isComponentType()) {
            // compound key
            ComponentType compType = (ComponentType) meta.getIdentifierType();
            int index = Arrays.asList(compType.getPropertyNames()).indexOf(foreignKeyName);
            if (index >= 0) {
                Object idComp = meta.getIdentifier(entity, null);
                id = compType.getPropertyValue(idComp, index, EntityMode.POJO);
            }
        }

        if (id == null && entityInfo.entityState == EntityState.Deleted) {
            id = entityInfo.originalValuesMap.get(foreignKeyName);
        }
        return id;
    }

    /**
     * Return the property value for the given entity.
     * @param meta
     * @param entity
     * @param propName If null, the identifier property will be returned.
     * @return
     */
    private Object getPropertyValue(ClassMetadata meta, Object entity, String propName) {
        if (propName == null || propName == meta.getIdentifierPropertyName()) return meta.getIdentifier(entity, null);
        else return meta.getPropertyValue(entity, propName);
    }

}