/*****************************************************************
 *   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.wocompat;

import java.io.FileNotFoundException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.collections.Predicate;
import org.apache.commons.collections.PredicateUtils;
import org.apache.cayenne.CayenneRuntimeException;
import org.apache.cayenne.dba.TypesMapping;
import org.apache.cayenne.exp.Expression;
import org.apache.cayenne.exp.parser.ASTDbPath;
import org.apache.cayenne.map.DataMap;
import org.apache.cayenne.map.DbEntity;
import org.apache.cayenne.map.DbJoin;
import org.apache.cayenne.map.DbRelationship;
import org.apache.cayenne.map.ObjEntity;
import org.apache.cayenne.map.ObjRelationship;
import org.apache.cayenne.project.NamedObjectFactory;
import org.apache.cayenne.query.AbstractQuery;
import org.apache.cayenne.query.Query;
import org.apache.cayenne.util.ResourceLocator;
import org.apache.cayenne.wocompat.parser.Parser;

/**
 * Class for converting stored Apple EOModel mapping files to Cayenne DataMaps.
 */
public class EOModelProcessor {

    protected Predicate prototypeChecker;

    public EOModelProcessor() {
        prototypeChecker = new Predicate() {

            public boolean evaluate(Object object) {
                if (object == null) {
                    return false;
                }

                String entityName = object.toString();
                return entityName.startsWith("EO") && entityName.endsWith("Prototypes");
            }
        };
    }

    /**
     * Returns index.eomodeld contents as a Map.
     * 
     * @since 1.1
     */
    // TODO: refactor EOModelHelper to provide a similar method without loading
    // all entity files in memory... here we simply copied stuff from EOModelHelper
    public Map loadModeIndex(String path) throws Exception {

        ResourceLocator locator = new ResourceLocator();
        locator.setSkipClasspath(false);
        locator.setSkipCurrentDirectory(false);
        locator.setSkipHomeDirectory(true);
        locator.setSkipAbsolutePath(false);

        if (!path.endsWith(".eomodeld")) {
            path += ".eomodeld";
        }

        URL base = locator.findDirectoryResource(path);
        if (base == null) {
            throw new FileNotFoundException("Can't find EOModel: " + path);
        }

        Parser plistParser = new Parser();
        InputStream in = new URL(base, "index.eomodeld").openStream();

        try {
            plistParser.ReInit(in);
            return (Map) plistParser.propertyList();
        }
        finally {
            in.close();
        }
    }

    /**
     * Performs EOModel loading.
     * 
     * @param path A path to ".eomodeld" directory. If path doesn't end with ".eomodeld",
     *            ".eomodeld" suffix is automatically assumed.
     */
    public DataMap loadEOModel(String path) throws Exception {
        return loadEOModel(path, false);
    }

    /**
     * Performs EOModel loading.
     * 
     * @param path A path to ".eomodeld" directory. If path doesn't end with ".eomodeld",
     *            ".eomodeld" suffix is automatically assumed.
     * @param generateClientClass if true then loading of EOModel is java client classes
     *            aware and the following processing will work with Java client class
     *            settings of the EOModel.
     */
    public DataMap loadEOModel(String path, boolean generateClientClass) throws Exception {
        EOModelHelper helper = makeHelper(path, generateClientClass);

        // create empty map
        DataMap dataMap = helper.getDataMap();

        // process enitities ... throw out prototypes ... for now
        List modelNames = new ArrayList(helper.modelNamesAsList());
        CollectionUtils.filter(modelNames, PredicateUtils.notPredicate(prototypeChecker));

        Iterator it = modelNames.iterator();
        while (it.hasNext()) {
            String name = (String) it.next();

            // create and register entity
            makeEntity(helper, name, generateClientClass);
        }

        // now sort following inheritance hierarchy
        Collections.sort(modelNames, new InheritanceComparator(dataMap));

        // after all entities are loaded, process attributes
        it = modelNames.iterator();
        while (it.hasNext()) {
            String name = (String) it.next();

            EOObjEntity e = (EOObjEntity) dataMap.getObjEntity(name);
            // process entity attributes
            makeAttributes(helper, e);
        }

        // after all entities are loaded, process relationships
        it = modelNames.iterator();
        while (it.hasNext()) {
            String name = (String) it.next();
            makeRelationships(helper, dataMap.getObjEntity(name));
        }

        // after all normal relationships are loaded, process flattened relationships
        it = modelNames.iterator();
        while (it.hasNext()) {
            String name = (String) it.next();
            makeFlatRelationships(helper, dataMap.getObjEntity(name));
        }

        // now create missing reverse DB (but not OBJ) relationships
        // since Cayenne requires them
        it = modelNames.iterator();
        while (it.hasNext()) {
            String name = (String) it.next();
            DbEntity dbEntity = dataMap.getObjEntity(name).getDbEntity();

            if (dbEntity != null) {
                makeReverseDbRelationships(dbEntity);
            }
        }

        // build SelectQueries out of EOFetchSpecifications...
        it = modelNames.iterator();
        while (it.hasNext()) {
            String name = (String) it.next();
            Iterator queries = helper.queryNames(name);
            while (queries.hasNext()) {
                String queryName = (String) queries.next();
                EOObjEntity entity = (EOObjEntity) dataMap.getObjEntity(name);
                makeQuery(helper, entity, queryName);
            }
        }

        return dataMap;
    }

    /**
     * Returns whether an Entity is an EOF EOPrototypes entity. According to EOF
     * conventions EOPrototypes and EO[Adapter]Prototypes entities are considered to be
     * prototypes.
     * 
     * @since 1.1
     */
    protected boolean isPrototypesEntity(String entityName) {
        return prototypeChecker.evaluate(entityName);
    }

    /**
     * Creates an returns new EOModelHelper to process EOModel. Exists mostly for the
     * benefit of subclasses.
     */
    protected EOModelHelper makeHelper(String path, boolean genereateClientClass)
            throws Exception {
        return new EOModelHelper(path);
    }

    /**
     * Creates a Cayenne query out of EOFetchSpecification data.
     * 
     * @since 1.1
     */
    protected Query makeQuery(EOModelHelper helper, EOObjEntity entity, String queryName) {

        DataMap dataMap = helper.getDataMap();
        Map queryPlist = helper.queryPListMap(entity.getName(), queryName);
        if (queryPlist == null) {
            return null;
        }
        
        AbstractQuery query;
        if (queryPlist.containsKey("hints")) { // just a predefined SQL query
        		query = new EOSQLQuery(entity, queryPlist);
        } else {
        		query = new EOQuery(entity, queryPlist);
        }
        query.setName(entity.qualifiedQueryName(queryName));
        dataMap.addQuery(query);
        
        return query;
    }

    /**
     * Creates and returns a new ObjEntity linked to a corresponding DbEntity.
     */
    protected EOObjEntity makeEntity(
            EOModelHelper helper,
            String name,
            boolean generateClientClass) {

        DataMap dataMap = helper.getDataMap();
        Map entityPlist = helper.entityPListMap(name);

        // create ObjEntity
        EOObjEntity objEntity = new EOObjEntity(name);
        objEntity.setEoMap(entityPlist);
        objEntity.setServerOnly(!generateClientClass);
        String parent = (String) entityPlist.get("parent");
        objEntity.setClassName(helper.entityClass(name, generateClientClass));

        if (parent != null) {
            objEntity.setSubclass(true);
            objEntity.setSuperClassName(helper.entityClass(parent, generateClientClass));
        }

        // add flag whether this entity is set as abstract in the model
        objEntity.setAbstractEntity("Y".equals(entityPlist.get("isAbstractEntity")));

        // create DbEntity...since EOF allows the same table to be
        // associated with multiple EOEntities, check for name duplicates
        String dbEntityName = (String) entityPlist.get("externalName");
        if (dbEntityName != null) {

            // ... if inheritance is involved and parent hierarchy uses the same DBEntity,
            // do not create a DbEntity...
            boolean createDbEntity = true;
            if (parent != null) {
                String parentName = parent;
                while (parentName != null) {
                    Map parentData = helper.entityPListMap(parentName);
                    if (parentData == null) {
                        break;
                    }

                    String parentExternalName = (String) parentData.get("externalName");
                    if (parentExternalName == null) {
                        parentName = (String) parentData.get("parent");
                        continue;
                    }

                    if (dbEntityName.equals(parentExternalName)) {
                        createDbEntity = false;
                    }

                    break;
                }
            }

            if (createDbEntity) {
                int i = 0;
                String dbEntityBaseName = dbEntityName;
                while (dataMap.getDbEntity(dbEntityName) != null) {
                    dbEntityName = dbEntityBaseName + i++;
                }

                objEntity.setDbEntityName(dbEntityName);
                DbEntity de = new DbEntity(dbEntityName);
                dataMap.addDbEntity(de);
            }
        }

        // set various flags
        objEntity.setReadOnly("Y".equals(entityPlist.get("isReadOnly")));
        objEntity.setSuperEntityName((String) entityPlist.get("parent"));

        dataMap.addObjEntity(objEntity);

        return objEntity;
    }

    /**
     * Create ObjAttributes of the specified entity, as well as DbAttributes of the
     * corresponding DbEntity.
     */
    protected void makeAttributes(EOModelHelper helper, EOObjEntity objEntity) {
        Map entityPlistMap = helper.entityPListMap(objEntity.getName());
        List primaryKeys = (List) entityPlistMap.get("primaryKeyAttributes");

        List classProperties;
        if (objEntity.isServerOnly()) {
            classProperties = (List) entityPlistMap.get("classProperties");
        }
        else {
            classProperties = (List) entityPlistMap.get("clientClassProperties");
        }

        List attributes = (List) entityPlistMap.get("attributes");
        DbEntity dbEntity = objEntity.getDbEntity();

        if (primaryKeys == null) {
            primaryKeys = Collections.EMPTY_LIST;
        }

        if (classProperties == null) {
            classProperties = Collections.EMPTY_LIST;
        }

        if (attributes == null) {
            attributes = Collections.EMPTY_LIST;
        }

        // detect single table inheritance
        boolean singleTableInheritance = false;
        String parentName = (String) entityPlistMap.get("parent");
        while (parentName != null) {
            Map parentData = helper.entityPListMap(parentName);
            if (parentData == null) {
                break;
            }

            String parentExternalName = (String) parentData.get("externalName");
            if (parentExternalName == null) {
                parentName = (String) parentData.get("parent");
                continue;
            }

            if (dbEntity.getName() != null
                    && dbEntity.getName().equals(parentExternalName)) {
                singleTableInheritance = true;
            }

            break;
        }

        Iterator it = attributes.iterator();
        while (it.hasNext()) {
            Map attrMap = (Map) it.next();

            String prototypeName = (String) attrMap.get("prototypeName");
            Map prototypeAttrMap = helper.getPrototypeAttributeMapFor(prototypeName);

            String dbAttrName = (String) attrMap.get("columnName");
            if (null == dbAttrName) {
                dbAttrName = (String) prototypeAttrMap.get("columnName");
            }

            String attrName = (String) attrMap.get("name");
            if (null == attrName) {
                attrName = (String) prototypeAttrMap.get("name");
            }

            String attrType = (String) attrMap.get("valueClassName");
            if (null == attrType) {
                attrType = (String) prototypeAttrMap.get("valueClassName");
            }

            String valueType = (String) attrMap.get("valueType");
            if (valueType == null) {
                valueType = (String) prototypeAttrMap.get("valueType");
            }

            String javaType = helper.javaTypeForEOModelerType(attrType, valueType);
            EODbAttribute dbAttr = null;

            if (dbAttrName != null && dbEntity != null) {

                // if inherited attribute, skip it for DbEntity...
                if (!singleTableInheritance || dbEntity.getAttribute(dbAttrName) == null) {

                    // create DbAttribute...since EOF allows the same column name for
                    // more than one Java attribute, we need to check for name duplicates
                    int i = 0;
                    String dbAttributeBaseName = dbAttrName;
                    while (dbEntity.getAttribute(dbAttrName) != null) {
                        dbAttrName = dbAttributeBaseName + i++;
                    }

                    dbAttr = new EODbAttribute(dbAttrName, TypesMapping
                            .getSqlTypeByJava(javaType), dbEntity);
                    dbAttr.setEoAttributeName(attrName);
                    dbEntity.addAttribute(dbAttr);

                    Integer width = (Integer) attrMap.get("width");
                    if (null == width)
                        width = (Integer) prototypeAttrMap.get("width");

                    if (width != null)
                        dbAttr.setMaxLength(width.intValue());

                    Integer scale = (Integer) attrMap.get("scale");
                    if (null == scale)
                        scale = (Integer) prototypeAttrMap.get("scale");

                    if (scale != null)
                        dbAttr.setPrecision(scale.intValue());

                    if (primaryKeys.contains(attrName))
                        dbAttr.setPrimaryKey(true);

                    Object allowsNull = attrMap.get("allowsNull");
                    // TODO: Unclear that allowsNull should be inherited from EOPrototypes
                    // if (null == allowsNull) allowsNull =
                    // prototypeAttrMap.get("allowsNull");;

                    dbAttr.setMandatory(!"Y".equals(allowsNull));
                }
            }

            if (classProperties.contains(attrName)) {
                EOObjAttribute attr = new EOObjAttribute(attrName, javaType, objEntity);

                // set readOnly flag of Attribute if either attribute is read or
                // if entity is readOnly
                String entityReadOnlyString = (String) entityPlistMap.get("isReadOnly");
                String attributeReadOnlyString = (String) attrMap.get("isReadOnly");
                if ("Y".equals(entityReadOnlyString)
                        || "Y".equals(attributeReadOnlyString)) {
                    attr.setReadOnly(true);
                }

                // set name instead of the actual attribute, as it may be inherited....
                attr.setDbAttributeName(dbAttrName);
                objEntity.addAttribute(attr);
            }
        }
    }

    /**
     * Create ObjRelationships of the specified entity, as well as DbRelationships of the
     * corresponding DbEntity.
     */
    protected void makeRelationships(EOModelHelper helper, ObjEntity objEntity) {
        Map entityPlistMap = helper.entityPListMap(objEntity.getName());
        List classProps = (List) entityPlistMap.get("classProperties");
        List rinfo = (List) entityPlistMap.get("relationships");

        Collection attributes = (Collection) entityPlistMap.get("attributes");

        if (rinfo == null) {
            return;
        }

        if (classProps == null) {
            classProps = Collections.EMPTY_LIST;
        }

        if (attributes == null) {
            attributes = Collections.EMPTY_LIST;
        }

        DbEntity dbSrc = objEntity.getDbEntity();
        Iterator it = rinfo.iterator();
        while (it.hasNext()) {
            Map relMap = (Map) it.next();
            String targetName = (String) relMap.get("destination");

            // ignore flattened relationships for now
            if (targetName == null) {
                continue;
            }

            String relName = (String) relMap.get("name");
            boolean toMany = "Y".equals(relMap.get("isToMany"));
            boolean toDependentPK = "Y".equals(relMap.get("propagatesPrimaryKey"));
            ObjEntity target = helper.getDataMap().getObjEntity(targetName);

            // target maybe null for cross-EOModel relationships
            // ignoring those now.
            if (target == null) {
                continue;
            }

            DbEntity dbTarget = target.getDbEntity();
            Map targetPlistMap = helper.entityPListMap(targetName);
            Collection targetAttributes = (Collection) targetPlistMap.get("attributes");
            DbRelationship dbRel = null;

            // process underlying DbRelationship
            // Note: there is no flattened rel. support here....
            // Note: source maybe null, e.g. an abstract entity.
            if (dbSrc != null && dbTarget != null) {

                // in case of inheritance EOF stores duplicates of all inherited
                // relationships, so we must skip this relationship in DB entity if it is
                // already there...

                dbRel = (DbRelationship) dbSrc.getRelationship(relName);
                if (dbRel == null) {

                    dbRel = new DbRelationship();
                    dbRel.setSourceEntity(dbSrc);
                    dbRel.setTargetEntity(dbTarget);
                    dbRel.setToMany(toMany);
                    dbRel.setName(relName);
                    dbRel.setToDependentPK(toDependentPK);
                    dbSrc.addRelationship(dbRel);

                    List joins = (List) relMap.get("joins");
                    Iterator jIt = joins.iterator();
                    while (jIt.hasNext()) {
                        Map joinMap = (Map) jIt.next();

                        DbJoin join = new DbJoin(dbRel);

                        // find source attribute dictionary and extract the column name
                        String sourceAttributeName = (String) joinMap
                                .get("sourceAttribute");
                        join.setSourceName(columnName(attributes, sourceAttributeName));

                        String targetAttributeName = (String) joinMap
                                .get("destinationAttribute");

                        join.setTargetName(columnName(
                                targetAttributes,
                                targetAttributeName));
                        dbRel.addJoin(join);
                    }
                }
            }

            // only create obj relationship if it is a class property
            if (classProps.contains(relName)) {
                ObjRelationship rel = new ObjRelationship();
                rel.setName(relName);
                rel.setSourceEntity(objEntity);
                rel.setTargetEntity(target);
                objEntity.addRelationship(rel);

                if (dbRel != null) {
                    rel.addDbRelationship(dbRel);
                }
            }
        }
    }

    /**
     * Create reverse DbRelationships that were not created so far, since Cayenne requires
     * them.
     * 
     * @since 1.0.5
     */
    protected void makeReverseDbRelationships(DbEntity dbEntity) {
        if (dbEntity == null) {
            throw new NullPointerException(
                    "Attempt to create reverse relationships for the null DbEntity.");
        }

        // iterate over a copy of the collection, since in case of
        // reflexive relationships, we may modify source entity relationship map
        Collection clone = new ArrayList(dbEntity.getRelationships());
        Iterator it = clone.iterator();
        while (it.hasNext()) {
            DbRelationship relationship = (DbRelationship) it.next();

            if (relationship.getReverseRelationship() == null) {
                DbRelationship reverse = relationship.createReverseRelationship();

                String name = NamedObjectFactory.createName(DbRelationship.class, reverse
                        .getSourceEntity(), relationship.getName() + "Reverse");
                reverse.setName(name);
                relationship.getTargetEntity().addRelationship(reverse);
            }
        }
    }

    /**
     * Create Flattened ObjRelationships of the specified entity.
     */
    protected void makeFlatRelationships(EOModelHelper helper, ObjEntity e) {
        Map info = helper.entityPListMap(e.getName());
        List rinfo = (List) info.get("relationships");
        if (rinfo == null) {
            return;
        }

        Iterator it = rinfo.iterator();
        while (it.hasNext()) {
            Map relMap = (Map) it.next();
            String targetPath = (String) relMap.get("definition");

            // ignore normal relationships
            if (targetPath == null) {
                continue;
            }

            ObjRelationship flatRel = new ObjRelationship();
            flatRel.setName((String) relMap.get("name"));
            flatRel.setSourceEntity(e);
            e.addRelationship(flatRel);

            DbEntity dbEntity = e.getDbEntity();
            if (dbEntity == null) {
                // not ready to handle inheritance from abstract entities...
                continue;
            }

            // determine DB relationship mapping...
            Expression exp = new ASTDbPath(targetPath);
            Iterator path = dbEntity.resolvePathComponents(exp);

            DbRelationship firstRel = null;
            DbRelationship lastRel = null;
            while (path.hasNext()) {
                lastRel = (DbRelationship) path.next();
                flatRel.addDbRelationship(lastRel);

                if (firstRel == null) {
                    firstRel = lastRel;
                }
            }

            if ((firstRel != null) && (lastRel != null)) {
                Collection potentialTargets = e.getDataMap().getMappedEntities(
                        (DbEntity) lastRel.getTargetEntity());

                // sanity check
                if (potentialTargets.size() != 1) {
                    throw new CayenneRuntimeException(
                            "One and only one entity should be mapped"
                                    + " to "
                                    + lastRel.getTargetEntity().getName()
                                    + ". Instead found : "
                                    + potentialTargets.size());
                }

                flatRel.setTargetEntity((ObjEntity) potentialTargets.iterator().next());
            }
            else {
                throw new CayenneRuntimeException("relationship in the path was null!");
            }
        }

    }

    /**
     * Locates an attribute map matching the name and returns column name for this
     * attribute.
     * 
     * @since 1.1
     */
    String columnName(Collection entityAttributes, String attributeName) {
        if (attributeName == null) {
            return null;
        }

        Iterator it = entityAttributes.iterator();
        while (it.hasNext()) {
            Map map = (Map) it.next();
            if (attributeName.equals(map.get("name"))) {
                return (String) map.get("columnName");
            }
        }

        return null;
    }

    // sorts ObjEntities so that subentities in inheritance hierarchy are shown last
    final class InheritanceComparator implements Comparator {

        DataMap dataMap;

        InheritanceComparator(DataMap dataMap) {
            this.dataMap = dataMap;
        }

        public int compare(Object o1, Object o2) {
            if (o1 == null) {
                return o2 != null ? -1 : 0;
            }
            else if (o2 == null) {
                return 1;
            }

            String name1 = o1.toString();
            String name2 = o2.toString();

            ObjEntity e1 = dataMap.getObjEntity(name1);
            ObjEntity e2 = dataMap.getObjEntity(name2);

            return compareEntities(e1, e2);
        }

        int compareEntities(ObjEntity e1, ObjEntity e2) {
            if (e1 == null) {
                return e2 != null ? -1 : 0;
            }
            else if (e2 == null) {
                return 1;
            }

            // entity goes first if it is a direct or indirect superentity of another
            // one
            if (e1.isSubentityOf(e2)) {
                return 1;
            }

            if (e2.isSubentityOf(e1)) {
                return -1;
            }

            // sort alphabetically
            return e1.getName().compareTo(e2.getName());
        }
    }
}
