package io.agrest.cayenne.processor.update;

import io.agrest.AgException;
import io.agrest.AgObjectId;
import io.agrest.CompoundObjectId;
import io.agrest.EntityUpdate;
import io.agrest.NestedResourceEntity;
import io.agrest.ObjectMapper;
import io.agrest.ObjectMapperFactory;
import io.agrest.ResourceEntity;
import io.agrest.SimpleObjectId;
import io.agrest.cayenne.persister.ICayennePersister;
import io.agrest.meta.AgAttribute;
import io.agrest.meta.AgRelationship;
import io.agrest.runtime.meta.IMetadataService;
import io.agrest.runtime.processor.update.ByIdObjectMapperFactory;
import io.agrest.runtime.processor.update.UpdateContext;
import org.apache.cayenne.DataObject;
import org.apache.cayenne.di.Inject;
import org.apache.cayenne.exp.Expression;
import org.apache.cayenne.exp.ExpressionFactory;
import org.apache.cayenne.exp.Property;
import org.apache.cayenne.map.ObjRelationship;
import org.apache.cayenne.query.SelectQuery;

import javax.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;

/**
 * @since 2.7
 */
public class CayenneUpdateStage extends CayenneUpdateDataStoreStage {


    public CayenneUpdateStage(
            @Inject IMetadataService metadataService,
            @Inject ICayennePersister persister) {

        super(metadataService, persister.entityResolver());
    }

    @Override
    protected <T extends DataObject> void sync(UpdateContext<T> context) {

        ObjectMapper<T> mapper = createObjectMapper(context);

        Map<Object, Collection<EntityUpdate<T>>> keyMap = mutableKeyMap(context, mapper);

        for (T o : itemsForKeys(context, keyMap.keySet(), mapper)) {
            Object key = mapper.keyForObject(o);

            Collection<EntityUpdate<T>> updates = keyMap.remove(key);

            // a null can only mean some algorithm malfunction
            if (updates == null) {
                throw new AgException(Response.Status.INTERNAL_SERVER_ERROR, "Invalid key item: " + key);
            }

            updateSingle(context, o, updates);
        }

        // check leftovers - those correspond to objects missing in the DB or
        // objects with no keys
        afterUpdatesMerge(context, keyMap);
    }

    protected <T extends DataObject> void afterUpdatesMerge(UpdateContext<T> context, Map<Object, Collection<EntityUpdate<T>>> keyMap) {
        if (!keyMap.isEmpty()) {
            Object firstKey = keyMap.keySet().iterator().next();

            if (firstKey == null) {
                throw new AgException(Response.Status.BAD_REQUEST, "Can't update. No id for object");
            }

            throw new AgException(Response.Status.NOT_FOUND,
                    "No object for ID '" + firstKey + "' and entity '" + context.getEntity().getName() + "'");
        }
    }

    protected <T extends DataObject> Map<Object, Collection<EntityUpdate<T>>> mutableKeyMap(UpdateContext<T> context, ObjectMapper<T> mapper) {

        Collection<EntityUpdate<T>> updates = context.getUpdates();

        // sizing the map with one-update per key assumption
        Map<Object, Collection<EntityUpdate<T>>> map = new HashMap<>((int) (updates.size() / 0.75));

        for (EntityUpdate<T> u : updates) {
            Object key = mapper.keyForUpdate(u);
            map.computeIfAbsent(key, k -> new ArrayList<>(2)).add(u);
        }

        return map;
    }

    protected <T extends DataObject> ObjectMapper<T> createObjectMapper(UpdateContext<T> context) {
        ObjectMapperFactory mapper = context.getMapper() != null
                ? context.getMapper()
                : ByIdObjectMapperFactory.mapper();
        return mapper.createMapper(context);
    }

    <T extends DataObject> List<T> itemsForKeys(UpdateContext<T> context, Collection<Object> keys, ObjectMapper<T> mapper) {

        // TODO: split query in batches:
        // respect Constants.SERVER_MAX_ID_QUALIFIER_SIZE_PROPERTY
        // property of Cayenne , breaking query into subqueries.
        // Otherwise this operation will not scale.. Though I guess since we are
        // not using streaming API to read data from Cayenne, we are already
        // limited in how much data can fit in the memory map.

        List<Expression> expressions = new ArrayList<>(keys.size());
        for (Object key : keys) {

            Expression e = mapper.expressionForKey(key);
            if (e != null) {
                expressions.add(e);
            }
        }

        // no keys or all keys were for non-persistent objects
        if (expressions.isEmpty()) {
            return Collections.emptyList();
        }

        ResourceEntity resourceEntity = context.getEntity();
        resourceEntity.setQualifier(ExpressionFactory.joinExp(Expression.OR, expressions));

        buildQuery(context, context.getEntity());

        List<T> objects = fetchEntity(context, resourceEntity);
        if (context.isById() && objects.size() > 1) {
            throw new AgException(Response.Status.INTERNAL_SERVER_ERROR, String.format(
                    "Found more than one object for ID '%s' and entity '%s'",
                    context.getId(), context.getEntity().getName()));
        }

        return objects;
    }


    <T> SelectQuery<T> buildQuery(UpdateContext<T> context, ResourceEntity<T> entity) {

        SelectQuery<T> query = SelectQuery.query(entity.getType());

        // apply various request filters identifying the span of the collection

        if (entity.getQualifier() != null) {
            query.andQualifier(entity.getQualifier());
        }

        entity.setSelect(query);

        buildChildrenQuery(context, entity, entity.getChildren());

        return query;
    }

    protected void buildChildrenQuery(UpdateContext context, ResourceEntity<?> entity, Map<String, NestedResourceEntity<?>> children) {
        if (!children.isEmpty()) {
            for (Map.Entry<String, NestedResourceEntity<?>> e : children.entrySet()) {
                NestedResourceEntity child = e.getValue();

                if (entityResolver.getObjEntity(child.getType()) == null) {
                    continue;
                }

                List<Property> properties = new ArrayList<>();
                properties.add(Property.createSelf(child.getType()));

                ObjRelationship objRelationship = objRelationshipForIncomingRelationship(child);

                for (AgAttribute attribute : entity.getAgEntity().getIds()) {
                    properties.add(Property.create(ExpressionFactory.dbPathExp(
                            objRelationship.getReverseDbRelationshipPath() + "." + attribute.getName()),
                            (Class) attribute.getType()));
                }
                // transfer expression from parent
                if (entity.getSelect().getQualifier() != null) {
                    child.andQualifier(translateExpressionToSource(objRelationship, entity.getSelect().getQualifier()));
                }

                SelectQuery childQuery = buildQuery(context, child);
                childQuery.setColumns(properties);
            }
        }
    }


    protected <T> List<T> fetchEntity(UpdateContext<T> context, ResourceEntity<T> resourceEntity) {
        SelectQuery<T> select = resourceEntity.getSelect();

        List<T> objects = CayenneUpdateStartStage.cayenneContext(context).select(select);

        fetchChildren(context, resourceEntity, resourceEntity.getChildren());

        return objects;
    }

    protected <T> void fetchChildren(UpdateContext context, ResourceEntity<T> parent, Map<String, NestedResourceEntity<?>> children) {
        if (!children.isEmpty()) {
            for (Map.Entry<String, NestedResourceEntity<?>> e : children.entrySet()) {
                NestedResourceEntity childEntity = e.getValue();

                List childObjects = fetchEntity(context, childEntity);

                AgRelationship rel = parent.getChild(e.getKey()).getIncoming();

                assignChildrenToParent(
                        parent,
                        childObjects,
                        rel.isToMany()
                                ? (i, o) -> childEntity.addToManyResult(i, o)
                                : (i, o) -> childEntity.setToOneResult(i, o));
            }
        }
    }

    /**
     * Assigns child items to the appropriate parent item
     */
    protected <T> void assignChildrenToParent(ResourceEntity<T> parentEntity, List children, BiConsumer<AgObjectId, Object> resultKeeper) {
        // saves a result
        for (Object child : children) {
            if (child instanceof Object[]) {
                Object[] ids = (Object[]) child;
                if (ids.length == 2) {
                    resultKeeper.accept(new SimpleObjectId(ids[1]), (T) ids[0]);
                } else if (ids.length > 2) {
                    // saves entity with a compound ID
                    Map<String, Object> compoundKeys = new LinkedHashMap<>();
                    AgAttribute[] idAttributes = parentEntity.getAgEntity().getIds().toArray(new AgAttribute[0]);
                    if (idAttributes.length == (ids.length - 1)) {
                        for (int i = 1; i < ids.length; i++) {
                            compoundKeys.put(idAttributes[i - 1].getName(), ids[i]);
                        }
                    }
                    resultKeeper.accept(new CompoundObjectId(compoundKeys), (T) ids[0]);
                }
            }
        }
    }

    // TODO: copied verbatim from CayenneQueryAssembler... Unify this code?
    protected ObjRelationship objRelationshipForIncomingRelationship(NestedResourceEntity<?> entity) {
        return entityResolver.getObjEntity(entity.getParent().getName()).getRelationship(entity.getIncoming().getName());
    }

    // TODO: copied verbatim from CayenneQueryAssembler... Unify this code?
    protected Expression translateExpressionToSource(ObjRelationship relationship, Expression expression) {
        return expression != null
                ? relationship.getSourceEntity().translateToRelatedEntity(expression, relationship.getName())
                : null;
    }
}