// Copyright 2018 Google LLC
//
// 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.google.firebase.firestore.util;

import static com.google.firebase.firestore.util.ApiUtil.invoke;
import static com.google.firebase.firestore.util.ApiUtil.newInstance;

import com.google.firebase.Timestamp;
import com.google.firebase.firestore.Blob;
import com.google.firebase.firestore.DocumentId;
import com.google.firebase.firestore.DocumentReference;
import com.google.firebase.firestore.Exclude;
import com.google.firebase.firestore.FieldValue;
import com.google.firebase.firestore.GeoPoint;
import com.google.firebase.firestore.IgnoreExtraProperties;
import com.google.firebase.firestore.PropertyName;
import com.google.firebase.firestore.ServerTimestamp;
import com.google.firebase.firestore.ThrowOnExtraProperties;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.lang.reflect.WildcardType;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;

/** Helper class to convert to/from custom POJO classes and plain Java types. */
public class CustomClassMapper {
  /** Maximum depth before we give up and assume it's a recursive object graph. */
  private static final int MAX_DEPTH = 500;

  private static final ConcurrentMap<Class<?>, BeanMapper<?>> mappers = new ConcurrentHashMap<>();

  private static void hardAssert(boolean assertion) {
    hardAssert(assertion, "Internal inconsistency");
  }

  private static void hardAssert(boolean assertion, String message) {
    if (!assertion) {
      throw new RuntimeException("Hard assert failed: " + message);
    }
  }

  /**
   * Converts a Java representation of JSON data to standard library Java data types: Map, Array,
   * String, Double, Integer and Boolean. POJOs are converted to Java Maps.
   *
   * @param object The representation of the JSON data
   * @return JSON representation containing only standard library Java types
   */
  public static Object convertToPlainJavaTypes(Object object) {
    return serialize(object);
  }

  public static Map<String, Object> convertToPlainJavaTypes(Map<?, Object> update) {
    Object converted = serialize(update);
    hardAssert(converted instanceof Map);
    @SuppressWarnings("unchecked")
    Map<String, Object> convertedMap = (Map<String, Object>) converted;
    return convertedMap;
  }

  /**
   * Converts a standard library Java representation of JSON data to an object of the provided
   * class.
   *
   * @param object The representation of the JSON data
   * @param clazz The class of the object to convert to
   * @param docRef The value to set to {@link DocumentId} annotated fields in the custom class.
   * @return The POJO object.
   */
  public static <T> T convertToCustomClass(
      Object object, Class<T> clazz, DocumentReference docRef) {
    return deserializeToClass(object, clazz, new DeserializeContext(ErrorPath.EMPTY, docRef));
  }

  private static <T> Object serialize(T o) {
    return serialize(o, ErrorPath.EMPTY);
  }

  @SuppressWarnings("unchecked")
  private static <T> Object serialize(T o, ErrorPath path) {
    if (path.getLength() > MAX_DEPTH) {
      throw serializeError(
          path,
          "Exceeded maximum depth of "
              + MAX_DEPTH
              + ", which likely indicates there's an object cycle");
    }
    if (o == null) {
      return null;
    } else if (o instanceof Number) {
      if (o instanceof Long || o instanceof Integer || o instanceof Double || o instanceof Float) {
        return o;
      } else {
        throw serializeError(
            path,
            String.format(
                "Numbers of type %s are not supported, please use an int, long, float or double",
                o.getClass().getSimpleName()));
      }
    } else if (o instanceof String) {
      return o;
    } else if (o instanceof Boolean) {
      return o;
    } else if (o instanceof Character) {
      throw serializeError(path, "Characters are not supported, please use Strings");
    } else if (o instanceof Map) {
      Map<String, Object> result = new HashMap<>();
      for (Map.Entry<Object, Object> entry : ((Map<Object, Object>) o).entrySet()) {
        Object key = entry.getKey();
        if (key instanceof String) {
          String keyString = (String) key;
          result.put(keyString, serialize(entry.getValue(), path.child(keyString)));
        } else {
          throw serializeError(path, "Maps with non-string keys are not supported");
        }
      }
      return result;
    } else if (o instanceof Collection) {
      if (o instanceof List) {
        List<Object> list = (List<Object>) o;
        List<Object> result = new ArrayList<>(list.size());
        for (int i = 0; i < list.size(); i++) {
          result.add(serialize(list.get(i), path.child("[" + i + "]")));
        }
        return result;
      } else {
        throw serializeError(
            path, "Serializing Collections is not supported, please use Lists instead");
      }
    } else if (o.getClass().isArray()) {
      throw serializeError(path, "Serializing Arrays is not supported, please use Lists instead");
    } else if (o instanceof Enum) {
      String enumName = ((Enum<?>) o).name();
      try {
        Field enumField = o.getClass().getField(enumName);
        return BeanMapper.propertyName(enumField);
      } catch (NoSuchFieldException ex) {
        return enumName;
      }
    } else if (o instanceof Date
        || o instanceof Timestamp
        || o instanceof GeoPoint
        || o instanceof Blob
        || o instanceof DocumentReference
        || o instanceof FieldValue) {
      return o;
    } else {
      Class<T> clazz = (Class<T>) o.getClass();
      BeanMapper<T> mapper = loadOrCreateBeanMapperForClass(clazz);
      return mapper.serialize(o, path);
    }
  }

  @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"})
  private static <T> T deserializeToType(Object o, Type type, DeserializeContext context) {
    if (o == null) {
      return null;
    } else if (type instanceof ParameterizedType) {
      return deserializeToParameterizedType(o, (ParameterizedType) type, context);
    } else if (type instanceof Class) {
      return deserializeToClass(o, (Class<T>) type, context);
    } else if (type instanceof WildcardType) {
      Type[] lowerBounds = ((WildcardType) type).getLowerBounds();
      if (lowerBounds.length > 0) {
        throw deserializeError(
            context.errorPath, "Generic lower-bounded wildcard types are not supported");
      }

      // Upper bounded wildcards are of the form <? extends Foo>. Multiple upper bounds are allowed
      // but if any of the bounds are of class type, that bound must come first in this array. Note
      // that this array always has at least one element, since the unbounded wildcard <?> always
      // has at least an upper bound of Object.
      Type[] upperBounds = ((WildcardType) type).getUpperBounds();
      hardAssert(upperBounds.length > 0, "Unexpected type bounds on wildcard " + type);
      return deserializeToType(o, upperBounds[0], context);
    } else if (type instanceof TypeVariable) {
      // As above, TypeVariables always have at least one upper bound of Object.
      Type[] upperBounds = ((TypeVariable<?>) type).getBounds();
      hardAssert(upperBounds.length > 0, "Unexpected type bounds on type variable " + type);
      return deserializeToType(o, upperBounds[0], context);

    } else if (type instanceof GenericArrayType) {
      throw deserializeError(
          context.errorPath, "Generic Arrays are not supported, please use Lists instead");
    } else {
      throw deserializeError(context.errorPath, "Unknown type encountered: " + type);
    }
  }

  @SuppressWarnings("unchecked")
  private static <T> T deserializeToClass(Object o, Class<T> clazz, DeserializeContext context) {
    if (o == null) {
      return null;
    } else if (clazz.isPrimitive()
        || Number.class.isAssignableFrom(clazz)
        || Boolean.class.isAssignableFrom(clazz)
        || Character.class.isAssignableFrom(clazz)) {
      return deserializeToPrimitive(o, clazz, context);
    } else if (String.class.isAssignableFrom(clazz)) {
      return (T) convertString(o, context);
    } else if (Date.class.isAssignableFrom(clazz)) {
      return (T) convertDate(o, context);
    } else if (Timestamp.class.isAssignableFrom(clazz)) {
      return (T) convertTimestamp(o, context);
    } else if (Blob.class.isAssignableFrom(clazz)) {
      return (T) convertBlob(o, context);
    } else if (GeoPoint.class.isAssignableFrom(clazz)) {
      return (T) convertGeoPoint(o, context);
    } else if (DocumentReference.class.isAssignableFrom(clazz)) {
      return (T) convertDocumentReference(o, context);
    } else if (clazz.isArray()) {
      throw deserializeError(
          context.errorPath, "Converting to Arrays is not supported, please use Lists instead");
    } else if (clazz.getTypeParameters().length > 0) {
      throw deserializeError(
          context.errorPath,
          "Class "
              + clazz.getName()
              + " has generic type parameters, please use GenericTypeIndicator instead");
    } else if (clazz.equals(Object.class)) {
      return (T) o;
    } else if (clazz.isEnum()) {
      return deserializeToEnum(o, clazz, context);
    } else {
      return convertBean(o, clazz, context);
    }
  }

  @SuppressWarnings({"unchecked", "TypeParameterUnusedInFormals"})
  private static <T> T deserializeToParameterizedType(
      Object o, ParameterizedType type, DeserializeContext context) {
    // getRawType should always return a Class<?>
    Class<?> rawType = (Class<?>) type.getRawType();
    if (List.class.isAssignableFrom(rawType)) {
      Type genericType = type.getActualTypeArguments()[0];
      if (o instanceof List) {
        List<Object> list = (List<Object>) o;
        List<Object> result = new ArrayList<>(list.size());
        for (int i = 0; i < list.size(); i++) {
          result.add(
              deserializeToType(
                  list.get(i),
                  genericType,
                  context.newInstanceWithErrorPath(context.errorPath.child("[" + i + "]"))));
        }
        return (T) result;
      } else {
        throw deserializeError(context.errorPath, "Expected a List, but got a " + o.getClass());
      }
    } else if (Map.class.isAssignableFrom(rawType)) {
      Type keyType = type.getActualTypeArguments()[0];
      Type valueType = type.getActualTypeArguments()[1];
      if (!keyType.equals(String.class)) {
        throw deserializeError(
            context.errorPath,
            "Only Maps with string keys are supported, but found Map with key type " + keyType);
      }
      Map<String, Object> map = expectMap(o, context);
      HashMap<String, Object> result = new HashMap<>();
      for (Map.Entry<String, Object> entry : map.entrySet()) {
        result.put(
            entry.getKey(),
            deserializeToType(
                entry.getValue(),
                valueType,
                context.newInstanceWithErrorPath(context.errorPath.child(entry.getKey()))));
      }
      return (T) result;
    } else if (Collection.class.isAssignableFrom(rawType)) {
      throw deserializeError(
          context.errorPath, "Collections are not supported, please use Lists instead");
    } else {
      Map<String, Object> map = expectMap(o, context);
      BeanMapper<T> mapper = (BeanMapper<T>) loadOrCreateBeanMapperForClass(rawType);
      HashMap<TypeVariable<Class<T>>, Type> typeMapping = new HashMap<>();
      TypeVariable<Class<T>>[] typeVariables = mapper.clazz.getTypeParameters();
      Type[] types = type.getActualTypeArguments();
      if (types.length != typeVariables.length) {
        throw new IllegalStateException("Mismatched lengths for type variables and actual types");
      }
      for (int i = 0; i < typeVariables.length; i++) {
        typeMapping.put(typeVariables[i], types[i]);
      }
      return mapper.deserialize(map, typeMapping, context);
    }
  }

  @SuppressWarnings("unchecked")
  private static <T> T deserializeToPrimitive(
      Object o, Class<T> clazz, DeserializeContext context) {
    if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) {
      return (T) convertInteger(o, context);
    } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) {
      return (T) convertBoolean(o, context);
    } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) {
      return (T) convertDouble(o, context);
    } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) {
      return (T) convertLong(o, context);
    } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) {
      return (T) (Float) convertDouble(o, context).floatValue();
    } else {
      throw deserializeError(
          context.errorPath,
          String.format("Deserializing values to %s is not supported", clazz.getSimpleName()));
    }
  }

  @SuppressWarnings("unchecked")
  private static <T> T deserializeToEnum(
      Object object, Class<T> clazz, DeserializeContext context) {
    if (object instanceof String) {
      String value = (String) object;
      // We cast to Class without generics here since we can't prove the bound
      // T extends Enum<T> statically

      // try to use PropertyName if exist
      Field[] enumFields = clazz.getFields();
      for (Field field : enumFields) {
        if (field.isEnumConstant()) {
          String propertyName = BeanMapper.propertyName(field);
          if (value.equals(propertyName)) {
            value = field.getName();
            break;
          }
        }
      }

      try {
        return (T) Enum.valueOf((Class) clazz, value);
      } catch (IllegalArgumentException e) {
        throw deserializeError(
            context.errorPath,
            "Could not find enum value of " + clazz.getName() + " for value \"" + value + "\"");
      }
    } else {
      throw deserializeError(
          context.errorPath,
          "Expected a String while deserializing to enum "
              + clazz
              + " but got a "
              + object.getClass());
    }
  }

  private static <T> BeanMapper<T> loadOrCreateBeanMapperForClass(Class<T> clazz) {
    @SuppressWarnings("unchecked")
    BeanMapper<T> mapper = (BeanMapper<T>) mappers.get(clazz);
    if (mapper == null) {
      mapper = new BeanMapper<>(clazz);
      // Inserting without checking is fine because mappers are "pure" and it's okay
      // if we create and use multiple by different threads temporarily
      mappers.put(clazz, mapper);
    }
    return mapper;
  }

  @SuppressWarnings("unchecked")
  private static Map<String, Object> expectMap(Object object, DeserializeContext context) {
    if (object instanceof Map) {
      // TODO: runtime validation of keys?
      return (Map<String, Object>) object;
    } else {
      throw deserializeError(
          context.errorPath, "Expected a Map while deserializing, but got a " + object.getClass());
    }
  }

  private static Integer convertInteger(Object o, DeserializeContext context) {
    if (o instanceof Integer) {
      return (Integer) o;
    } else if (o instanceof Long || o instanceof Double) {
      double value = ((Number) o).doubleValue();
      if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) {
        return ((Number) o).intValue();
      } else {
        throw deserializeError(
            context.errorPath,
            "Numeric value out of 32-bit integer range: "
                + value
                + ". Did you mean to use a long or double instead of an int?");
      }
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert a value of type " + o.getClass().getName() + " to int");
    }
  }

  private static Long convertLong(Object o, DeserializeContext context) {
    if (o instanceof Integer) {
      return ((Integer) o).longValue();
    } else if (o instanceof Long) {
      return (Long) o;
    } else if (o instanceof Double) {
      Double value = (Double) o;
      if (value >= Long.MIN_VALUE && value <= Long.MAX_VALUE) {
        return value.longValue();
      } else {
        throw deserializeError(
            context.errorPath,
            "Numeric value out of 64-bit long range: "
                + value
                + ". Did you mean to use a double instead of a long?");
      }
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert a value of type " + o.getClass().getName() + " to long");
    }
  }

  private static Double convertDouble(Object o, DeserializeContext context) {
    if (o instanceof Integer) {
      return ((Integer) o).doubleValue();
    } else if (o instanceof Long) {
      Long value = (Long) o;
      Double doubleValue = ((Long) o).doubleValue();
      if (doubleValue.longValue() == value) {
        return doubleValue;
      } else {
        throw deserializeError(
            context.errorPath,
            "Loss of precision while converting number to "
                + "double: "
                + o
                + ". Did you mean to use a 64-bit long instead?");
      }
    } else if (o instanceof Double) {
      return (Double) o;
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert a value of type " + o.getClass().getName() + " to double");
    }
  }

  private static Boolean convertBoolean(Object o, DeserializeContext context) {
    if (o instanceof Boolean) {
      return (Boolean) o;
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to boolean");
    }
  }

  private static String convertString(Object o, DeserializeContext context) {
    if (o instanceof String) {
      return (String) o;
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to String");
    }
  }

  private static Date convertDate(Object o, DeserializeContext context) {
    if (o instanceof Date) {
      return (Date) o;
    } else if (o instanceof Timestamp) {
      return ((Timestamp) o).toDate();
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to Date");
    }
  }

  private static Timestamp convertTimestamp(Object o, DeserializeContext context) {
    if (o instanceof Timestamp) {
      return (Timestamp) o;
    } else if (o instanceof Date) {
      return new Timestamp((Date) o);
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to Timestamp");
    }
  }

  private static Blob convertBlob(Object o, DeserializeContext context) {
    if (o instanceof Blob) {
      return (Blob) o;
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to Blob");
    }
  }

  private static GeoPoint convertGeoPoint(Object o, DeserializeContext context) {
    if (o instanceof GeoPoint) {
      return (GeoPoint) o;
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to GeoPoint");
    }
  }

  private static DocumentReference convertDocumentReference(Object o, DeserializeContext context) {
    if (o instanceof DocumentReference) {
      return (DocumentReference) o;
    } else {
      throw deserializeError(
          context.errorPath,
          "Failed to convert value of type " + o.getClass().getName() + " to DocumentReference");
    }
  }

  private static <T> T convertBean(Object o, Class<T> clazz, DeserializeContext context) {
    BeanMapper<T> mapper = loadOrCreateBeanMapperForClass(clazz);
    if (o instanceof Map) {
      return mapper.deserialize(expectMap(o, context), context);
    } else {
      throw deserializeError(
          context.errorPath,
          "Can't convert object of type " + o.getClass().getName() + " to type " + clazz.getName());
    }
  }

  private static IllegalArgumentException serializeError(ErrorPath path, String reason) {
    reason = "Could not serialize object. " + reason;
    if (path.getLength() > 0) {
      reason = reason + " (found in field '" + path.toString() + "')";
    }
    return new IllegalArgumentException(reason);
  }

  private static RuntimeException deserializeError(ErrorPath path, String reason) {
    reason = "Could not deserialize object. " + reason;
    if (path.getLength() > 0) {
      reason = reason + " (found in field '" + path.toString() + "')";
    }
    return new RuntimeException(reason);
  }

  // Helper class to convert from maps to custom objects (Beans), and vice versa.
  private static class BeanMapper<T> {
    private final Class<T> clazz;
    private final Constructor<T> constructor;
    // Whether to throw exception if there are properties we don't know how to set to
    // custom object fields/setters during deserialization.
    private final boolean throwOnUnknownProperties;
    // Whether to log a message if there are properties we don't know how to set to
    // custom object fields/setters during deserialization.
    private final boolean warnOnUnknownProperties;

    // Case insensitive mapping of properties to their case sensitive versions
    private final Map<String, String> properties;

    // Below are maps to find getter/setter/field from a given property name.
    // A property name is the name annotated by @PropertyName, if exists; or their property name
    // following the Java Bean convention: field name is kept as-is while getters/setters will have
    // their prefixes removed. See method propertyName for details.
    private final Map<String, Method> getters;
    private final Map<String, Method> setters;
    private final Map<String, Field> fields;

    // A set of property names that were annotated with @ServerTimestamp.
    private final HashSet<String> serverTimestamps;

    // A set of property names that were annotated with @DocumentId. These properties will be
    // populated with document ID values during deserialization, and be skipped during
    // serialization.
    private final HashSet<String> documentIdPropertyNames;

    BeanMapper(Class<T> clazz) {
      this.clazz = clazz;
      throwOnUnknownProperties = clazz.isAnnotationPresent(ThrowOnExtraProperties.class);
      warnOnUnknownProperties = !clazz.isAnnotationPresent(IgnoreExtraProperties.class);
      properties = new HashMap<>();

      setters = new HashMap<>();
      getters = new HashMap<>();
      fields = new HashMap<>();

      serverTimestamps = new HashSet<>();
      documentIdPropertyNames = new HashSet<>();

      Constructor<T> constructor;
      try {
        constructor = clazz.getDeclaredConstructor();
        constructor.setAccessible(true);
      } catch (NoSuchMethodException e) {
        // We will only fail at deserialization time if no constructor is present
        constructor = null;
      }
      this.constructor = constructor;
      // Add any public getters to properties (including isXyz())
      for (Method method : clazz.getMethods()) {
        if (shouldIncludeGetter(method)) {
          String propertyName = propertyName(method);
          addProperty(propertyName);
          method.setAccessible(true);
          if (getters.containsKey(propertyName)) {
            throw new RuntimeException(
                "Found conflicting getters for name "
                    + method.getName()
                    + " on class "
                    + clazz.getName());
          }
          getters.put(propertyName, method);
          applyGetterAnnotations(method);
        }
      }

      // Add any public fields to properties
      for (Field field : clazz.getFields()) {
        if (shouldIncludeField(field)) {
          String propertyName = propertyName(field);
          addProperty(propertyName);
          applyFieldAnnotations(field);
        }
      }

      // We can use private setters and fields for known (public) properties/getters. Since
      // getMethods/getFields only returns public methods/fields we need to traverse the
      // class hierarchy to find the appropriate setter or field.
      Class<? super T> currentClass = clazz;
      do {
        // Add any setters
        for (Method method : currentClass.getDeclaredMethods()) {
          if (shouldIncludeSetter(method)) {
            String propertyName = propertyName(method);
            String existingPropertyName = properties.get(propertyName.toLowerCase(Locale.US));
            if (existingPropertyName != null) {
              if (!existingPropertyName.equals(propertyName)) {
                throw new RuntimeException(
                    "Found setter on "
                        + currentClass.getName()
                        + " with invalid case-sensitive name: "
                        + method.getName());
              } else {
                Method existingSetter = setters.get(propertyName);
                if (existingSetter == null) {
                  method.setAccessible(true);
                  setters.put(propertyName, method);
                  applySetterAnnotations(method);
                } else if (!isSetterOverride(method, existingSetter)) {
                  // We require that setters with conflicting property names are
                  // overrides from a base class
                  if (currentClass == clazz) {
                    // TODO: Should we support overloads?
                    throw new RuntimeException(
                        "Class "
                            + clazz.getName()
                            + " has multiple setter overloads with name "
                            + method.getName());
                  } else {
                    throw new RuntimeException(
                        "Found conflicting setters "
                            + "with name: "
                            + method.getName()
                            + " (conflicts with "
                            + existingSetter.getName()
                            + " defined on "
                            + existingSetter.getDeclaringClass().getName()
                            + ")");
                  }
                }
              }
            }
          }
        }

        for (Field field : currentClass.getDeclaredFields()) {
          String propertyName = propertyName(field);

          // Case sensitivity is checked at deserialization time
          // Fields are only added if they don't exist on a subclass
          if (properties.containsKey(propertyName.toLowerCase(Locale.US))
              && !fields.containsKey(propertyName)) {
            field.setAccessible(true);
            fields.put(propertyName, field);
            applyFieldAnnotations(field);
          }
        }

        // Traverse class hierarchy until we reach java.lang.Object which contains a bunch
        // of fields/getters we don't want to serialize
        currentClass = currentClass.getSuperclass();
      } while (currentClass != null && !currentClass.equals(Object.class));

      if (properties.isEmpty()) {
        throw new RuntimeException("No properties to serialize found on class " + clazz.getName());
      }

      // Make sure we can write to @DocumentId annotated properties before proceeding.
      for (String docIdProperty : documentIdPropertyNames) {
        if (!setters.containsKey(docIdProperty) && !fields.containsKey(docIdProperty)) {
          throw new RuntimeException(
              "@DocumentId is annotated on property "
                  + docIdProperty
                  + " of class "
                  + clazz.getName()
                  + " but no field or public setter was found");
        }
      }
    }

    private void addProperty(String property) {
      String oldValue = properties.put(property.toLowerCase(Locale.US), property);
      if (oldValue != null && !property.equals(oldValue)) {
        throw new RuntimeException(
            "Found two getters or fields with conflicting case "
                + "sensitivity for property: "
                + property.toLowerCase(Locale.US));
      }
    }

    T deserialize(Map<String, Object> values, DeserializeContext context) {
      return deserialize(values, Collections.emptyMap(), context);
    }

    T deserialize(
        Map<String, Object> values,
        Map<TypeVariable<Class<T>>, Type> types,
        DeserializeContext context) {
      if (constructor == null) {
        throw deserializeError(
            context.errorPath,
            "Class "
                + clazz.getName()
                + " does not define a no-argument constructor. If you are using ProGuard, make "
                + "sure these constructors are not stripped");
      }

      T instance = newInstance(constructor);
      HashSet<String> deserialzedProperties = new HashSet<>();
      for (Map.Entry<String, Object> entry : values.entrySet()) {
        String propertyName = entry.getKey();
        ErrorPath childPath = context.errorPath.child(propertyName);
        if (setters.containsKey(propertyName)) {
          Method setter = setters.get(propertyName);
          Type[] params = setter.getGenericParameterTypes();
          if (params.length != 1) {
            throw deserializeError(childPath, "Setter does not have exactly one parameter");
          }
          Type resolvedType = resolveType(params[0], types);
          Object value =
              CustomClassMapper.deserializeToType(
                  entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath));
          invoke(setter, instance, value);
          deserialzedProperties.add(propertyName);
        } else if (fields.containsKey(propertyName)) {
          Field field = fields.get(propertyName);
          Type resolvedType = resolveType(field.getGenericType(), types);
          Object value =
              CustomClassMapper.deserializeToType(
                  entry.getValue(), resolvedType, context.newInstanceWithErrorPath(childPath));
          try {
            field.set(instance, value);
          } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
          }
          deserialzedProperties.add(propertyName);
        } else {
          String message =
              "No setter/field for " + propertyName + " found on class " + clazz.getName();
          if (properties.containsKey(propertyName.toLowerCase(Locale.US))) {
            message += " (fields/setters are case sensitive!)";
          }
          if (throwOnUnknownProperties) {
            throw new RuntimeException(message);
          } else if (warnOnUnknownProperties) {
            Logger.warn(CustomClassMapper.class.getSimpleName(), "%s", message);
          }
        }
      }
      populateDocumentIdProperties(types, context, instance, deserialzedProperties);

      return instance;
    }

    // Populate @DocumentId annotated fields. If there is a conflict (@DocumentId annotation is
    // applied to a property that is already deserialized from the firestore document)
    // a runtime exception will be thrown.
    private void populateDocumentIdProperties(
        Map<TypeVariable<Class<T>>, Type> types,
        DeserializeContext context,
        T instance,
        HashSet<String> deserialzedProperties) {
      for (String docIdPropertyName : documentIdPropertyNames) {
        if (deserialzedProperties.contains(docIdPropertyName)) {
          String message =
              "'"
                  + docIdPropertyName
                  + "' was found from document "
                  + context.documentRef.getPath()
                  + ", cannot apply @DocumentId on this property for class "
                  + clazz.getName();
          throw new RuntimeException(message);
        }
        ErrorPath childPath = context.errorPath.child(docIdPropertyName);
        if (setters.containsKey(docIdPropertyName)) {
          Method setter = setters.get(docIdPropertyName);
          Type[] params = setter.getGenericParameterTypes();
          if (params.length != 1) {
            throw deserializeError(childPath, "Setter does not have exactly one parameter");
          }
          Type resolvedType = resolveType(params[0], types);
          if (resolvedType == String.class) {
            invoke(setter, instance, context.documentRef.getId());
          } else {
            invoke(setter, instance, context.documentRef);
          }
        } else {
          Field docIdField = fields.get(docIdPropertyName);
          try {
            if (docIdField.getType() == String.class) {
              docIdField.set(instance, context.documentRef.getId());
            } else {
              docIdField.set(instance, context.documentRef);
            }
          } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
          }
        }
      }
    }

    private Type resolveType(Type type, Map<TypeVariable<Class<T>>, Type> types) {
      if (type instanceof TypeVariable) {
        Type resolvedType = types.get(type);
        if (resolvedType == null) {
          throw new IllegalStateException("Could not resolve type " + type);
        } else {
          return resolvedType;
        }
      } else {
        return type;
      }
    }

    Map<String, Object> serialize(T object, ErrorPath path) {
      // TODO(wuandy): Add logic to skip @DocumentId annotated fields in serialization.
      if (!clazz.isAssignableFrom(object.getClass())) {
        throw new IllegalArgumentException(
            "Can't serialize object of class "
                + object.getClass()
                + " with BeanMapper for class "
                + clazz);
      }
      Map<String, Object> result = new HashMap<>();
      for (String property : properties.values()) {
        // Skip @DocumentId annotated properties;
        if (documentIdPropertyNames.contains(property)) {
          continue;
        }

        Object propertyValue;
        if (getters.containsKey(property)) {
          Method getter = getters.get(property);
          propertyValue = invoke(getter, object);
        } else {
          // Must be a field
          Field field = fields.get(property);
          if (field == null) {
            throw new IllegalStateException("Bean property without field or getter: " + property);
          }
          try {
            propertyValue = field.get(object);
          } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
          }
        }

        Object serializedValue;
        if (serverTimestamps.contains(property) && propertyValue == null) {
          // Replace null ServerTimestamp-annotated fields with the sentinel.
          serializedValue = FieldValue.serverTimestamp();
        } else {
          serializedValue = CustomClassMapper.serialize(propertyValue, path.child(property));
        }
        result.put(property, serializedValue);
      }
      return result;
    }

    private void applyFieldAnnotations(Field field) {
      if (field.isAnnotationPresent(ServerTimestamp.class)) {
        Class<?> fieldType = field.getType();
        if (fieldType != Date.class && fieldType != Timestamp.class) {
          throw new IllegalArgumentException(
              "Field "
                  + field.getName()
                  + " is annotated with @ServerTimestamp but is "
                  + fieldType
                  + " instead of Date or Timestamp.");
        }
        serverTimestamps.add(propertyName(field));
      }

      if (field.isAnnotationPresent(DocumentId.class)) {
        Class<?> fieldType = field.getType();
        ensureValidDocumentIdType("Field", "is", fieldType);
        documentIdPropertyNames.add(propertyName(field));
      }
    }

    private void applyGetterAnnotations(Method method) {
      if (method.isAnnotationPresent(ServerTimestamp.class)) {
        Class<?> returnType = method.getReturnType();
        if (returnType != Date.class && returnType != Timestamp.class) {
          throw new IllegalArgumentException(
              "Method "
                  + method.getName()
                  + " is annotated with @ServerTimestamp but returns "
                  + returnType
                  + " instead of Date or Timestamp.");
        }
        serverTimestamps.add(propertyName(method));
      }

      // Even though the value will be skipped, we still check for type matching for consistency.
      if (method.isAnnotationPresent(DocumentId.class)) {
        Class<?> returnType = method.getReturnType();
        ensureValidDocumentIdType("Method", "returns", returnType);
        documentIdPropertyNames.add(propertyName(method));
      }
    }

    private void applySetterAnnotations(Method method) {
      if (method.isAnnotationPresent(ServerTimestamp.class)) {
        throw new IllegalArgumentException(
            "Method "
                + method.getName()
                + " is annotated with @ServerTimestamp but should not be. @ServerTimestamp can"
                + " only be applied to fields and getters, not setters.");
      }

      if (method.isAnnotationPresent(DocumentId.class)) {
        Class<?> paramType = method.getParameterTypes()[0];
        ensureValidDocumentIdType("Method", "accepts", paramType);
        documentIdPropertyNames.add(propertyName(method));
      }
    }

    private void ensureValidDocumentIdType(String fieldDescription, String operation, Type type) {
      if (type != String.class && type != DocumentReference.class) {
        throw new IllegalArgumentException(
            fieldDescription
                + " is annotated with @DocumentId but "
                + operation
                + " "
                + type
                + " instead of String or DocumentReference.");
      }
    }

    private static boolean shouldIncludeGetter(Method method) {
      if (!method.getName().startsWith("get") && !method.getName().startsWith("is")) {
        return false;
      }
      // Exclude methods from Object.class
      if (method.getDeclaringClass().equals(Object.class)) {
        return false;
      }
      // Non-public methods
      if (!Modifier.isPublic(method.getModifiers())) {
        return false;
      }
      // Static methods
      if (Modifier.isStatic(method.getModifiers())) {
        return false;
      }
      // No return type
      if (method.getReturnType().equals(Void.TYPE)) {
        return false;
      }
      // Non-zero parameters
      if (method.getParameterTypes().length != 0) {
        return false;
      }
      // Excluded methods
      if (method.isAnnotationPresent(Exclude.class)) {
        return false;
      }
      return true;
    }

    private static boolean shouldIncludeSetter(Method method) {
      if (!method.getName().startsWith("set")) {
        return false;
      }
      // Exclude methods from Object.class
      if (method.getDeclaringClass().equals(Object.class)) {
        return false;
      }
      // Static methods
      if (Modifier.isStatic(method.getModifiers())) {
        return false;
      }
      // Has a return type
      if (!method.getReturnType().equals(Void.TYPE)) {
        return false;
      }
      // Methods without exactly one parameters
      if (method.getParameterTypes().length != 1) {
        return false;
      }
      // Excluded methods
      if (method.isAnnotationPresent(Exclude.class)) {
        return false;
      }
      return true;
    }

    private static boolean shouldIncludeField(Field field) {
      // Exclude methods from Object.class
      if (field.getDeclaringClass().equals(Object.class)) {
        return false;
      }
      // Non-public fields
      if (!Modifier.isPublic(field.getModifiers())) {
        return false;
      }
      // Static fields
      if (Modifier.isStatic(field.getModifiers())) {
        return false;
      }
      // Transient fields
      if (Modifier.isTransient(field.getModifiers())) {
        return false;
      }
      // Excluded fields
      if (field.isAnnotationPresent(Exclude.class)) {
        return false;
      }
      return true;
    }

    private static boolean isSetterOverride(Method base, Method override) {
      // We expect an overridden setter here
      hardAssert(
          base.getDeclaringClass().isAssignableFrom(override.getDeclaringClass()),
          "Expected override from a base class");
      hardAssert(base.getReturnType().equals(Void.TYPE), "Expected void return type");
      hardAssert(override.getReturnType().equals(Void.TYPE), "Expected void return type");

      Type[] baseParameterTypes = base.getParameterTypes();
      Type[] overrideParameterTypes = override.getParameterTypes();
      hardAssert(baseParameterTypes.length == 1, "Expected exactly one parameter");
      hardAssert(overrideParameterTypes.length == 1, "Expected exactly one parameter");

      return base.getName().equals(override.getName())
          && baseParameterTypes[0].equals(overrideParameterTypes[0]);
    }

    private static String propertyName(Field field) {
      String annotatedName = annotatedName(field);
      return annotatedName != null ? annotatedName : field.getName();
    }

    private static String propertyName(Method method) {
      String annotatedName = annotatedName(method);
      return annotatedName != null ? annotatedName : serializedName(method.getName());
    }

    private static String annotatedName(AccessibleObject obj) {
      if (obj.isAnnotationPresent(PropertyName.class)) {
        PropertyName annotation = obj.getAnnotation(PropertyName.class);
        return annotation.value();
      }

      return null;
    }

    private static String serializedName(String methodName) {
      String[] prefixes = new String[] {"get", "set", "is"};
      String methodPrefix = null;
      for (String prefix : prefixes) {
        if (methodName.startsWith(prefix)) {
          methodPrefix = prefix;
        }
      }
      if (methodPrefix == null) {
        throw new IllegalArgumentException("Unknown Bean prefix for method: " + methodName);
      }
      String strippedName = methodName.substring(methodPrefix.length());

      // Make sure the first word or upper-case prefix is converted to lower-case
      char[] chars = strippedName.toCharArray();
      int pos = 0;
      while (pos < chars.length && Character.isUpperCase(chars[pos])) {
        chars[pos] = Character.toLowerCase(chars[pos]);
        pos++;
      }
      return new String(chars);
    }
  }

  /**
   * Immutable class representing the path to a specific field in an object. Used to provide better
   * error messages.
   */
  static class ErrorPath {
    private final int length;
    private final ErrorPath parent;
    private final String name;

    static final ErrorPath EMPTY = new ErrorPath(null, null, 0);

    ErrorPath(ErrorPath parent, String name, int length) {
      this.parent = parent;
      this.name = name;
      this.length = length;
    }

    int getLength() {
      return length;
    }

    ErrorPath child(String name) {
      return new ErrorPath(this, name, length + 1);
    }

    @Override
    public String toString() {
      if (length == 0) {
        return "";
      } else if (length == 1) {
        return name;
      } else {
        // This is not very efficient, but it's only hit if there's an error.
        return parent.toString() + "." + name;
      }
    }
  }

  /** Holds information a deserialization operation needs to complete the job. */
  static class DeserializeContext {

    /** Current path to the field being deserialized, used for better error messages. */
    final ErrorPath errorPath;

    /** Value used to set to {@link DocumentId} annotated fields during deserialization, if any. */
    final DocumentReference documentRef;

    DeserializeContext(ErrorPath path, DocumentReference docRef) {
      errorPath = path;
      documentRef = docRef;
    }

    DeserializeContext newInstanceWithErrorPath(ErrorPath newPath) {
      return new DeserializeContext(newPath, documentRef);
    }
  }
}