/*
 * 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.time.Instant;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Calendar;
import java.util.Collection;
import java.util.Date;
import java.util.UUID;

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.IncompleteKey;
import com.google.cloud.datastore.Key;
import com.google.cloud.datastore.NullValue;
import com.google.cloud.datastore.Value;
import com.google.cloud.datastore.ValueBuilder;
import com.google.cloud.datastore.ValueType;
import com.jmethods.catatumbo.DatastoreKey;
import com.jmethods.catatumbo.EntityManagerException;
import com.jmethods.catatumbo.Indexer;
import com.jmethods.catatumbo.impl.IdentifierMetadata.DataType;

/**
 * Converts application's entities (POJOs) to the format needed for the underlying Cloud Datastore
 * API.
 *
 * @author Sai Pullabhotla
 */
public class Marshaller {

  /**
   * The intent of marshalling an object. The marshalling may be different depending on the intended
   * purpose. For example, if marshalling an object for UPDATE operation, the marshaller does not
   * automatically generate a key.
   * 
   * @author Sai Pullabhotla
   *
   */
  public enum Intent {
    /**
     * Insert
     */
    INSERT(false, false),

    /**
     * Update
     */
    UPDATE(true, false),

    /**
     * Upsert (Update or Insert)
     */
    UPSERT(false, false),

    /**
     * Delete
     */
    DELETE(true, true),

    /**
     * Batch Update
     */
    BATCH_UPDATE(true, false);

    /**
     * If a complete key is required for this Intent
     */
    private boolean keyRequired;

    /**
     * If this Intent (or operation) is valid on projected entities
     */
    private boolean validOnProjectedEntities;

    /**
     * Creates a new instance of <code>Intent</code>.
     * 
     * @param keyRequired
     *          whether or not a complete key is required.
     * @param validOnProjectedEntities
     *          whether or not this intent is valid on projected entities
     */
    private Intent(boolean keyRequired, boolean validOnProjectedEntities) {
      this.keyRequired = keyRequired;
      this.validOnProjectedEntities = validOnProjectedEntities;
    }

    /**
     * Tells whether or not a complete key is required for this Intent.
     * 
     * @return <code>true</code>, if a complete key is required; <code>false</code>, otherwise.
     */
    public boolean isKeyRequired() {
      return keyRequired;
    }

    /**
     * Tells whether or not this intent is valid on projected entities.
     * 
     * @return <code>true</code>, if this intent is valid/supported on projected entities;
     *         <code>false</code>, otherwise.
     */
    public boolean isValidOnProjectedEntities() {
      return validOnProjectedEntities;
    }

  }

  /**
   * Reference to the EntityManager
   */
  private DefaultEntityManager entityManager;

  /**
   * Entity being marshaled
   */
  private final Object entity;

  /**
   * Metadata of the entity being marshaled
   */
  private final EntityMetadata entityMetadata;

  /**
   * Builder for building the Entity needed for Cloud Datastore
   */
  private BaseEntity.Builder<?, ?> entityBuilder;

  /**
   * Key
   */
  private IncompleteKey key;

  /**
   * The intent of marshalling
   */
  private final Intent intent;

  /**
   * Creates a new instance of <code>Marshaller</code>.
   *
   * @param entityManager
   *          reference to the entity manager
   * @param entity
   *          the Entity to marshal
   * @param intent
   *          the intent of marshalling
   */
  private Marshaller(DefaultEntityManager entityManager, Object entity, Intent intent) {
    this.entityManager = entityManager;
    this.entity = entity;
    this.intent = intent;
    entityMetadata = EntityIntrospector.introspect(entity.getClass());
    validateIntent();
  }

  /**
   * Validates if the Intent is legal for the entity being marshalled.
   * 
   * @throws EntityManagerException
   *           if the Intent is not valid for the entity being marshalled
   */
  private void validateIntent() {
    if (entityMetadata.isProjectedEntity() && !intent.isValidOnProjectedEntities()) {
      String message = String.format("Operation %s is not allowed for ProjectedEntity %s", intent,
          entity.getClass().getName());
      throw new EntityManagerException(message);
    }
  }

  /**
   * Marshals the given entity (POJO) into the format needed for the low level Cloud Datastore API.
   * 
   * @param entityManager
   *          the entity manager
   * @param entity
   *          the entity to marshal
   * @param intent
   *          the intent or purpose of marshalling. Marshalling process varies slightly depending on
   *          the purpose. For example, if the purpose if INSERT or UPSERT, the marshaller would
   *          auto generate any keys. Where as if the purpose is UPDATE, then then marshaller will
   *          NOT generate any keys.
   * @return the marshaled object
   */
  @SuppressWarnings("rawtypes")
  public static BaseEntity marshal(DefaultEntityManager entityManager, Object entity,
      Intent intent) {
    Marshaller marshaller = new Marshaller(entityManager, entity, intent);
    return marshaller.marshal();
  }

  /**
   * Marshals the given entity and and returns the equivalent Entity needed for the underlying Cloud
   * Datastore API.
   * 
   * @return A native entity that is equivalent to the POJO being marshalled. The returned value
   *         could either be a FullEntity or Entity.
   */
  private BaseEntity<?> marshal() {
    marshalKey();
    if (key instanceof Key) {
      entityBuilder = Entity.newBuilder((Key) key);
    } else {
      entityBuilder = FullEntity.newBuilder(key);
    }
    marshalFields();
    marshalAutoTimestampFields();
    if (intent == Intent.UPDATE) {
      marshalVersionField();
    }
    marshalEmbeddedFields();
    return entityBuilder.build();
  }

  /**
   * Extracts the key from the given object, entity, and returns it. The entity must have its ID
   * set.
   *
   * @param entityManager
   *          the entity manager.
   * @param entity
   *          the entity from which key is to be extracted
   * @return extracted key.
   */
  public static Key marshalKey(DefaultEntityManager entityManager, Object entity) {
    Marshaller marshaller = new Marshaller(entityManager, entity, Intent.DELETE);
    marshaller.marshalKey();
    return (Key) marshaller.key;
  }

  /**
   * Marshals the key.
   */
  private void marshalKey() {

    Key parent = null;

    ParentKeyMetadata parentKeyMetadata = entityMetadata.getParentKeyMetadata();
    if (parentKeyMetadata != null) {
      DatastoreKey parentDatastoreKey = (DatastoreKey) getFieldValue(parentKeyMetadata);
      if (parentDatastoreKey != null) {
        parent = parentDatastoreKey.nativeKey();
      }
    }

    IdentifierMetadata identifierMetadata = entityMetadata.getIdentifierMetadata();
    IdClassMetadata idClassMetadata = identifierMetadata.getIdClassMetadata();
    DataType identifierType = identifierMetadata.getDataType();
    Object idValue = getFieldValue(identifierMetadata);

    // If ID value is null, we don't have to worry about if it is a simple
    // type of a complex type. Otherwise, we need to see if the ID is a
    // complex type, and if it is, extract the real ID.
    if (idValue != null && idClassMetadata != null) {
      try {
        idValue = idClassMetadata.getReadMethod().invoke(idValue);
      } catch (Throwable t) {
        throw new EntityManagerException(t);
      }
    }

    boolean validId = isValidId(idValue, identifierType);
    boolean autoGenerateId = identifierMetadata.isAutoGenerated();

    if (validId) {
      if (identifierType == DataType.STRING) {
        createCompleteKey(parent, (String) idValue);
      } else {
        createCompleteKey(parent, (long) idValue);
      }
    } else {
      if (intent.isKeyRequired()) {
        throw new EntityManagerException(String
            .format("Identifier is not set or valid for entity of type %s", entity.getClass()));
      }
      if (!autoGenerateId) {
        String pattern = "Identifier is not set or valid for entity of type %s. Auto generation "
            + "of ID is explicitly turned off. ";
        throw new EntityManagerException(String.format(pattern, entity.getClass()));
      } else {
        if (identifierType == DataType.STRING) {
          createCompleteKey(parent);
        } else {
          createIncompleteKey(parent);
        }
      }
    }
  }

  /**
   * Checks to see if the given value is a valid identifier for the given ID type.
   * 
   * @param idValue
   *          the ID value
   * @param identifierType
   *          the identifier type
   * @return <code>true</code>, if the given value is a valid identifier; <code>false</code>,
   *         otherwise. For STRING type, the ID is valid if it it contains at least one printable
   *         character. In other words, if ((String) idValue).trim().length() > 0. For numeric
   *         types, the ID is valid if it is not <code>null</code> or zero.
   */
  private static boolean isValidId(Object idValue, DataType identifierType) {
    boolean validId = false;
    if (idValue != null) {
      switch (identifierType) {
        case LONG:
        case LONG_OBJECT:
          validId = (long) idValue != 0;
          break;
        case STRING:
          validId = ((String) idValue).trim().length() > 0;
          break;
        default:
          // we should never get here
          break;
      }
    }
    return validId;
  }

  /**
   * Creates a complete key using the given parameters.
   * 
   * @param parent
   *          the parent key, may be <code>null</code>.
   * @param id
   *          the numeric ID
   */
  private void createCompleteKey(Key parent, long id) {
    String kind = entityMetadata.getKind();
    if (parent == null) {
      key = entityManager.newNativeKeyFactory().setKind(kind).newKey(id);
    } else {
      key = Key.newBuilder(parent, kind, id).build();
    }
  }

  /**
   * Creates a complete key using the given parameters.
   * 
   * @param parent
   *          the parent key, may be <code>null</code>.
   * @param id
   *          the String ID
   */
  private void createCompleteKey(Key parent, String id) {
    String kind = entityMetadata.getKind();
    if (parent == null) {
      key = entityManager.newNativeKeyFactory().setKind(kind).newKey(id);
    } else {
      key = Key.newBuilder(parent, kind, id).build();
    }
  }

  /**
   * Creates a CompleteKey using the given parameters. The actual ID is generated using
   * <code>UUID.randomUUID().toString()</code>.
   * 
   * @param parent
   *          the parent key, may be <code>null</code>.
   */
  private void createCompleteKey(Key parent) {
    String kind = entityMetadata.getKind();
    String id = UUID.randomUUID().toString();
    if (parent == null) {
      key = entityManager.newNativeKeyFactory().setKind(kind).newKey(id);
    } else {
      key = Key.newBuilder(parent, kind, id).build();
    }
  }

  /**
   * Creates an IncompleteKey.
   * 
   * @param parent
   *          the parent key, may be <code>null</code>.
   */
  private void createIncompleteKey(Key parent) {
    String kind = entityMetadata.getKind();
    if (parent == null) {
      key = entityManager.newNativeKeyFactory().setKind(kind).newKey();
    } else {
      key = IncompleteKey.newBuilder(parent, kind).build();
    }
  }

  /**
   * Marshals all the fields.
   */
  private void marshalFields() {
    Collection<PropertyMetadata> propertyMetadataCollection = entityMetadata
        .getPropertyMetadataCollection();
    for (PropertyMetadata propertyMetadata : propertyMetadataCollection) {
      marshalField(propertyMetadata, entity);
    }
  }

  /**
   * Marshals the field with the given property metadata.
   * 
   * @param propertyMetadata
   *          the metadata of the field to be marshaled.
   * @param target
   *          the object in which the field is defined/accessible from.
   */
  private void marshalField(PropertyMetadata propertyMetadata, Object target) {
    marshalField(propertyMetadata, target, entityBuilder);
  }

  /**
   * Marshals the field with the given property metadata.
   * 
   * @param propertyMetadata
   *          the metadata of the field to be marshaled.
   * @param target
   *          the object in which the field is defined/accessible from
   * @param entityBuilder
   *          the native entity on which the marshaled field should be set
   */
  private static void marshalField(PropertyMetadata propertyMetadata, Object target,
      BaseEntity.Builder<?, ?> entityBuilder) {
    Object fieldValue = IntrospectionUtils.getFieldValue(propertyMetadata, target);
    if (fieldValue == null && propertyMetadata.isOptional()) {
      return;
    }
    ValueBuilder<?, ?, ?> valueBuilder = propertyMetadata.getMapper().toDatastore(fieldValue);
    // ListValues cannot have indexing turned off. Indexing is turned on by
    // default, so we don't touch excludeFromIndexes for ListValues.
    if (valueBuilder.getValueType() != ValueType.LIST) {
      valueBuilder.setExcludeFromIndexes(!propertyMetadata.isIndexed());
    }
    Value<?> datastoreValue = valueBuilder.build();
    entityBuilder.set(propertyMetadata.getMappedName(), datastoreValue);
    Indexer indexer = propertyMetadata.getSecondaryIndexer();
    if (indexer != null) {
      entityBuilder.set(propertyMetadata.getSecondaryIndexName(), indexer.index(datastoreValue));
    }
  }

  /**
   * Returns the value for the given field of the entity being marshaled.
   *
   * @param fieldMetadata
   *          the field's metadata
   * @return the field's value
   */
  private Object getFieldValue(FieldMetadata fieldMetadata) {
    return IntrospectionUtils.getFieldValue(fieldMetadata, entity);
  }

  /**
   * Marshals the embedded fields.
   */
  private void marshalEmbeddedFields() {
    for (EmbeddedMetadata embeddedMetadata : entityMetadata.getEmbeddedMetadataCollection()) {
      if (embeddedMetadata.getStorageStrategy() == StorageStrategy.EXPLODED) {
        marshalWithExplodedStrategy(embeddedMetadata, entity);
      } else {
        ValueBuilder<?, ?, ?> embeddedEntityBuilder = marshalWithImplodedStrategy(embeddedMetadata,
            entity);
        if (embeddedEntityBuilder != null) {
          entityBuilder.set(embeddedMetadata.getMappedName(), embeddedEntityBuilder.build());
        }
      }
    }

  }

  /**
   * Marshals an embedded field represented by the given metadata.
   * 
   * @param embeddedMetadata
   *          the metadata of the embedded field
   * @param target
   *          the target object to which the embedded object belongs
   */
  private void marshalWithExplodedStrategy(EmbeddedMetadata embeddedMetadata, Object target) {
    try {
      Object embeddedObject = initializeEmbedded(embeddedMetadata, target);
      for (PropertyMetadata propertyMetadata : embeddedMetadata.getPropertyMetadataCollection()) {
        marshalField(propertyMetadata, embeddedObject);
      }
      for (EmbeddedMetadata embeddedMetadata2 : embeddedMetadata.getEmbeddedMetadataCollection()) {
        marshalWithExplodedStrategy(embeddedMetadata2, embeddedObject);
      }
    } catch (Throwable t) {
      throw new EntityManagerException(t);
    }
  }

  /**
   * Marshals the embedded field represented by the given metadata.
   * 
   * @param embeddedMetadata
   *          the metadata of the embedded field.
   * @param target
   *          the object in which the embedded field is defined/accessible from.
   * @return the ValueBuilder equivalent to embedded object
   */
  private ValueBuilder<?, ?, ?> marshalWithImplodedStrategy(EmbeddedMetadata embeddedMetadata,
      Object target) {
    try {
      Object embeddedObject = embeddedMetadata.getReadMethod().invoke(target);
      if (embeddedObject == null) {
        if (embeddedMetadata.isOptional()) {
          return null;
        }
        NullValue.Builder nullValueBuilder = NullValue.newBuilder();
        nullValueBuilder.setExcludeFromIndexes(!embeddedMetadata.isIndexed());
        return nullValueBuilder;
      }
      FullEntity.Builder<IncompleteKey> embeddedEntityBuilder = FullEntity.newBuilder();
      for (PropertyMetadata propertyMetadata : embeddedMetadata.getPropertyMetadataCollection()) {
        marshalField(propertyMetadata, embeddedObject, embeddedEntityBuilder);
      }
      for (EmbeddedMetadata embeddedMetadata2 : embeddedMetadata.getEmbeddedMetadataCollection()) {
        ValueBuilder<?, ?, ?> embeddedEntityBuilder2 = marshalWithImplodedStrategy(
            embeddedMetadata2, embeddedObject);
        if (embeddedEntityBuilder2 != null) {
          embeddedEntityBuilder.set(embeddedMetadata2.getMappedName(),
              embeddedEntityBuilder2.build());
        }
      }
      EntityValue.Builder valueBuilder = EntityValue.newBuilder(embeddedEntityBuilder.build());
      valueBuilder.setExcludeFromIndexes(!embeddedMetadata.isIndexed());
      return valueBuilder;

    } catch (Throwable t) {
      throw new EntityManagerException(t);
    }

  }

  /**
   * Marshals the the automatic timestamp fields, if any.
   */
  private void marshalAutoTimestampFields() {
    switch (intent) {
      case UPDATE:
      case UPSERT:
      case BATCH_UPDATE:
        marshalUpdatedTimestamp();
        break;
      case INSERT:
        marshalCreatedAndUpdatedTimestamp();
        break;
      default:
        break;
    }
  }

  /**
   * Marshals the updated timestamp field.
   */
  private void marshalUpdatedTimestamp() {
    PropertyMetadata updatedTimestampMetadata = entityMetadata.getUpdatedTimestampMetadata();
    if (updatedTimestampMetadata != null) {
      applyAutoTimestamp(updatedTimestampMetadata, System.currentTimeMillis());
    }
  }

  /**
   * Marshals both created and updated timestamp fields.
   */
  private void marshalCreatedAndUpdatedTimestamp() {
    PropertyMetadata createdTimestampMetadata = entityMetadata.getCreatedTimestampMetadata();
    PropertyMetadata updatedTimestampMetadata = entityMetadata.getUpdatedTimestampMetadata();
    long millis = System.currentTimeMillis();
    if (createdTimestampMetadata != null) {
      applyAutoTimestamp(createdTimestampMetadata, millis);
    }
    if (updatedTimestampMetadata != null) {
      applyAutoTimestamp(updatedTimestampMetadata, millis);
    }
  }

  /**
   * Applies the given time, <code>millis</code>, to the property represented by the given metadata.
   * 
   * @param propertyMetadata
   *          the property metadata of the field
   * @param millis
   *          the time in milliseconds
   */
  private void applyAutoTimestamp(PropertyMetadata propertyMetadata, long millis) {
    Object timestamp = null;
    Class<?> fieldType = propertyMetadata.getDeclaredType();
    if (Date.class.equals(fieldType)) {
      timestamp = new Date(millis);
    } else if (Calendar.class.equals(fieldType)) {
      Calendar calendar = new Calendar.Builder().setInstant(millis).build();
      timestamp = calendar;
    } else if (Long.class.equals(fieldType) || long.class.equals(fieldType)) {
      timestamp = millis;
    } else if (OffsetDateTime.class.equals(fieldType)) {
      timestamp = OffsetDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault());
    } else if (ZonedDateTime.class.equals(fieldType)) {
      timestamp = ZonedDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault());
    }
    ValueBuilder<?, ?, ?> valueBuilder = propertyMetadata.getMapper().toDatastore(timestamp);
    valueBuilder.setExcludeFromIndexes(!propertyMetadata.isIndexed());
    entityBuilder.set(propertyMetadata.getMappedName(), valueBuilder.build());
  }

  /**
   * Marshals the version field, if it exists. The version will be set to one more than the previous
   * value.
   */
  private void marshalVersionField() {
    PropertyMetadata versionMetadata = entityMetadata.getVersionMetadata();
    if (versionMetadata != null) {
      long version = (long) IntrospectionUtils.getFieldValue(versionMetadata, entity);
      ValueBuilder<?, ?, ?> valueBuilder = versionMetadata.getMapper().toDatastore(version + 1);
      valueBuilder.setExcludeFromIndexes(!versionMetadata.isIndexed());
      entityBuilder.set(versionMetadata.getMappedName(), valueBuilder.build());
    }
  }

  /**
   * 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 {
      // If instantiation of Entity instantiated the embeddable, we will
      // use the pre-initialized embedded object.
      Object embeddedObject = embeddedMetadata.getReadMethod().invoke(target);
      if (embeddedObject == null) {
        // Otherwise, we will instantiate the embedded object, which
        // could be a Builder
        embeddedObject = IntrospectionUtils.instantiate(embeddedMetadata);
        ConstructorMetadata constructorMetadata = embeddedMetadata.getConstructorMetadata();
        if (constructorMetadata.isBuilderConstructionStrategy()) {
          // Build the Builder
          embeddedObject = constructorMetadata.getBuildMethodHandle().invoke(embeddedObject);
        } else {
          // TODO we should not be doing this?? There is no equivalent
          // of this for builder pattern
          embeddedMetadata.getWriteMethod().invoke(target, embeddedObject);
        }
      }
      return embeddedObject;
    } catch (Throwable t) {
      throw new EntityManagerException(t);
    }
  }

}