/*
 * Copyright 2016 Sai Pullabhotla.
 *
 * Licensed 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 com.jmethods.catatumbo.impl;

import java.lang.invoke.MethodHandle;
import java.util.Collection;

import com.google.cloud.datastore.BaseEntity;
import com.google.cloud.datastore.Entity;
import com.google.cloud.datastore.EntityValue;
import com.google.cloud.datastore.FullEntity;
import com.google.cloud.datastore.Key;
import com.google.cloud.datastore.NullValue;
import com.google.cloud.datastore.ProjectionEntity;
import com.google.cloud.datastore.Value;
import com.jmethods.catatumbo.DefaultDatastoreKey;
import com.jmethods.catatumbo.EntityManagerException;

/**
 * Converts Entities retrieved from the Cloud Datastore into Entity POJOs.
 *
 * @author Sai Pullabhotla
 */
public class Unmarshaller {

  /**
   * Input - Native Entity to unmarshal, could be a ProjectionEntity or an Entity
   */
  private final BaseEntity<?> nativeEntity;

  /**
   * Output - unmarshalled object
   */
  private Object entity;

  /**
   * Entity metadata
   */
  private final EntityMetadata entityMetadata;

  /**
   * Creates a new instance of <code>Unmarshaller</code>.
   * 
   * @param nativeEntity
   *          the native entity to unmarshal
   * @param entityClass
   *          the expected model type
   */
  private Unmarshaller(BaseEntity<?> nativeEntity, Class<?> entityClass) {
    this.nativeEntity = nativeEntity;
    entityMetadata = EntityIntrospector.introspect(entityClass);

  }

  /**
   * Unmarshals the given native Entity into an object of given type, entityClass.
   * 
   * @param <T>
   *          target object type
   * @param nativeEntity
   *          the native Entity
   * @param entityClass
   *          the target type
   * @return Object that is equivalent to the given native entity. If the given
   *         <code>datastoreEntity</code> is <code>null</code>, returns <code>null</code>.
   */
  public static <T> T unmarshal(Entity nativeEntity, Class<T> entityClass) {
    return unmarshalBaseEntity(nativeEntity, entityClass);
  }

  /**
   * Unmarshals the given native ProjectionEntity into an object of given type, entityClass.
   * 
   * @param <T>
   *          target object type
   * @param nativeEntity
   *          the native Entity
   * @param entityClass
   *          the target type
   * @return Object that is equivalent to the given native entity. If the given
   *         <code>datastoreEntity</code> is <code>null</code>, returns <code>null</code>.
   */
  public static <T> T unmarshal(ProjectionEntity nativeEntity, Class<T> entityClass) {
    return unmarshalBaseEntity(nativeEntity, entityClass);
  }

  /**
   * Unmarshals the given Datastore Entity and returns the equivalent Entity POJO.
   *
   * @param <T>
   *          type
   * @return the entity POJO
   */
  @SuppressWarnings("unchecked")
  private <T> T unmarshal() {

    try {
      instantiateEntity();
      unmarshalIdentifier();
      unmarshalKeyAndParentKey();
      unmarshalProperties();
      unmarshalEmbeddedFields();
      // If using Builder pattern, invoke build method on the Builder to
      // get the final entity.
      ConstructorMetadata constructorMetadata = entityMetadata.getConstructorMetadata();
      if (constructorMetadata.isBuilderConstructionStrategy()) {
        entity = constructorMetadata.getBuildMethodHandle().invoke(entity);
      }
      return (T) entity;
    } catch (EntityManagerException exp) {
      throw exp;
    } catch (Throwable t) {
      throw new EntityManagerException(t.getMessage(), t);
    }
  }

  /**
   * Unmarshals the given BaseEntity and returns the equivalent model object.
   * 
   * @param nativeEntity
   *          the native entity to unmarshal
   * @param entityClass
   *          the target type of the model class
   * @return the model object
   */
  private static <T> T unmarshalBaseEntity(BaseEntity<?> nativeEntity, Class<T> entityClass) {
    if (nativeEntity == null) {
      return null;
    }
    Unmarshaller unmarshaller = new Unmarshaller(nativeEntity, entityClass);
    return unmarshaller.unmarshal();
  }

  /**
   * Instantiates the entity.
   */
  private void instantiateEntity() {
    entity = IntrospectionUtils.instantiate(entityMetadata);
  }

  /**
   * Unamrshals the identifier.
   * 
   * @throws Throwable
   *           propagated
   */
  private void unmarshalIdentifier() throws Throwable {
    IdentifierMetadata identifierMetadata = entityMetadata.getIdentifierMetadata();
    Object id = ((Key) nativeEntity.getKey()).getNameOrId();
    // If the ID is not a simple type...
    IdClassMetadata idClassMetadata = identifierMetadata.getIdClassMetadata();
    if (idClassMetadata != null) {
      Object wrappedId = idClassMetadata.getConstructor().invoke(id);
      id = wrappedId;
    }
    // Now set the ID (either simple or complex) on the Entity
    MethodHandle writeMethod = identifierMetadata.getWriteMethod();
    writeMethod.invoke(entity, id);
  }

  /**
   * Unamrshals the entity's key and parent key.
   * 
   * @throws Throwable
   *           propagated
   * 
   */
  private void unmarshalKeyAndParentKey() throws Throwable {
    KeyMetadata keyMetadata = entityMetadata.getKeyMetadata();
    if (keyMetadata != null) {
      MethodHandle writeMethod = keyMetadata.getWriteMethod();
      Key entityKey = (Key) nativeEntity.getKey();
      writeMethod.invoke(entity, new DefaultDatastoreKey(entityKey));
    }

    ParentKeyMetadata parentKeyMetadata = entityMetadata.getParentKeyMetadata();
    if (parentKeyMetadata != null) {
      MethodHandle writeMethod = parentKeyMetadata.getWriteMethod();
      Key parentKey = nativeEntity.getKey().getParent();
      if (parentKey != null) {
        writeMethod.invoke(entity, new DefaultDatastoreKey(parentKey));
      }
    }
  }

  /**
   * Unmarshal all the properties.
   * 
   * @throws Throwable
   *           propagated
   */
  private void unmarshalProperties() throws Throwable {
    Collection<PropertyMetadata> propertyMetadataCollection = entityMetadata
        .getPropertyMetadataCollection();
    for (PropertyMetadata propertyMetadata : propertyMetadataCollection) {
      unmarshalProperty(propertyMetadata, entity);
    }
  }

  /**
   * Unmarshals the embedded fields of this entity.
   * 
   * @throws Throwable
   *           propagated
   */
  private void unmarshalEmbeddedFields() throws Throwable {
    for (EmbeddedMetadata embeddedMetadata : entityMetadata.getEmbeddedMetadataCollection()) {
      if (embeddedMetadata.getStorageStrategy() == StorageStrategy.EXPLODED) {
        unmarshalWithExplodedStrategy(embeddedMetadata, entity);
      } else {
        unmarshalWithImplodedStrategy(embeddedMetadata, entity, nativeEntity);
      }
    }
  }

  /**
   * Unmarshals the embedded field represented by the given embedded metadata.
   * 
   * @param embeddedMetadata
   *          the embedded metadata
   * @param target
   *          the target object that needs to be updated
   * @throws Throwable
   *           propagated
   */
  private void unmarshalWithExplodedStrategy(EmbeddedMetadata embeddedMetadata, Object target)
      throws Throwable {
    Object embeddedObject = initializeEmbedded(embeddedMetadata, target);
    for (PropertyMetadata propertyMetadata : embeddedMetadata.getPropertyMetadataCollection()) {
      unmarshalProperty(propertyMetadata, embeddedObject);
    }
    for (EmbeddedMetadata embeddedMetadata2 : embeddedMetadata.getEmbeddedMetadataCollection()) {
      unmarshalWithExplodedStrategy(embeddedMetadata2, embeddedObject);
    }
    ConstructorMetadata constructorMetadata = embeddedMetadata.getConstructorMetadata();
    if (constructorMetadata.isBuilderConstructionStrategy()) {
      embeddedObject = constructorMetadata.getBuildMethodHandle().invoke(embeddedObject);
    }
    embeddedMetadata.getWriteMethod().invoke(target, embeddedObject);
  }

  /**
   * Unmarshals the embedded field represented by the given metadata.
   * 
   * @param embeddedMetadata
   *          the metadata of the field to unmarshal
   * @param target
   *          the object in which the embedded field is declared/accessible from
   * @param nativeEntity
   *          the native entity from which the embedded entity is to be extracted
   * @throws Throwable
   *           propagated
   */
  private static void unmarshalWithImplodedStrategy(EmbeddedMetadata embeddedMetadata,
      Object target, BaseEntity<?> nativeEntity) throws Throwable {
    Object embeddedObject = null;
    ConstructorMetadata constructorMetadata = embeddedMetadata.getConstructorMetadata();
    FullEntity<?> nativeEmbeddedEntity = null;
    String propertyName = embeddedMetadata.getMappedName();
    if (nativeEntity.contains(propertyName)) {
      Value<?> nativeValue = nativeEntity.getValue(propertyName);
      if (nativeValue instanceof NullValue) {
        embeddedMetadata.getWriteMethod().invoke(target, embeddedObject);
      } else {
        nativeEmbeddedEntity = ((EntityValue) nativeValue).get();
        embeddedObject = constructorMetadata.getConstructorMethodHandle().invoke();
      }
    }
    if (embeddedObject == null) {
      return;
    }
    for (PropertyMetadata propertyMetadata : embeddedMetadata.getPropertyMetadataCollection()) {
      unmarshalProperty(propertyMetadata, embeddedObject, nativeEmbeddedEntity);
    }
    for (EmbeddedMetadata embeddedMetadata2 : embeddedMetadata.getEmbeddedMetadataCollection()) {
      unmarshalWithImplodedStrategy(embeddedMetadata2, embeddedObject, nativeEmbeddedEntity);
    }
    if (constructorMetadata.isBuilderConstructionStrategy()) {
      embeddedObject = constructorMetadata.getBuildMethodHandle().invoke(embeddedObject);
    }
    embeddedMetadata.getWriteMethod().invoke(target, embeddedObject);
  }

  /**
   * Unmarshals the property represented by the given property metadata and updates the target
   * object with the property value.
   * 
   * @param propertyMetadata
   *          the property metadata
   * @param target
   *          the target object to update
   * @throws Throwable
   *           propagated
   */
  private void unmarshalProperty(PropertyMetadata propertyMetadata, Object target)
      throws Throwable {
    unmarshalProperty(propertyMetadata, target, nativeEntity);
  }

  /**
   * Unmarshals the property with the given metadata and sets the unmarshalled value on the given
   * <code>target</code> object.
   * 
   * @param propertyMetadata
   *          the metadata of the property
   * @param target
   *          the target object to set the unmarshalled value on
   * @param nativeEntity
   *          the native entity containing the source property
   * @throws Throwable
   *           propagated
   */
  private static void unmarshalProperty(PropertyMetadata propertyMetadata, Object target,
      BaseEntity<?> nativeEntity) throws Throwable {
    // The datastore may not have every property that the entity class has
    // defined. For example, if we are running a projection query or if the
    // entity class added a new field without updating existing data...So
    // make sure there is a property or else, we get an exception from the
    // datastore.
    if (nativeEntity.contains(propertyMetadata.getMappedName())) {
      Value<?> datastoreValue = nativeEntity.getValue(propertyMetadata.getMappedName());
      Object entityValue = propertyMetadata.getMapper().toModel(datastoreValue);
      MethodHandle writeMethod = propertyMetadata.getWriteMethod();
      writeMethod.invoke(target, entityValue);
    }
  }

  /**
   * Initializes the Embedded object represented by the given metadata.
   * 
   * @param embeddedMetadata
   *          the metadata of the embedded field
   * @param target
   *          the object in which the embedded field is declared/accessible from
   * @return the initialized object
   * @throws EntityManagerException
   *           if any error occurs during initialization of the embedded object
   */
  private static Object initializeEmbedded(EmbeddedMetadata embeddedMetadata, Object target) {
    try {
      ConstructorMetadata constructorMetadata = embeddedMetadata.getConstructorMetadata();
      Object embeddedObject = null;
      if (constructorMetadata.isClassicConstructionStrategy()) {
        embeddedObject = embeddedMetadata.getReadMethod().invoke(target);
      }
      if (embeddedObject == null) {
        embeddedObject = constructorMetadata.getConstructorMethodHandle().invoke();
      }
      return embeddedObject;
    } catch (Throwable t) {
      throw new EntityManagerException(t);
    }
  }

}