/*
 * 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.solr.client.solrj.beans;

import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Array;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.nio.ByteBuffer;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.util.SuppressForbidden;

/**
 * A class to map objects to and from solr documents.
 *
 * @since solr 1.3
 */
public class DocumentObjectBinder {

  private final Map<Class<?>, List<DocField>> infocache = new ConcurrentHashMap<>();

  public DocumentObjectBinder() {}

  public <T> List<T> getBeans(Class<T> clazz, SolrDocumentList solrDocList) {
    List<DocField> fields = getDocFields(clazz);
    List<T> result = new ArrayList<>(solrDocList.size());

    for (SolrDocument sdoc : solrDocList) {
      result.add(getBean(clazz, fields, sdoc));
    }
    return result;
  }

  public <T> T getBean(Class<T> clazz, SolrDocument solrDoc) {
    return getBean(clazz, null, solrDoc);
  }

  private <T> T getBean(Class<T> clazz, List<DocField> fields, SolrDocument solrDoc) {
    if (fields == null) {
      fields = getDocFields(clazz);
    }

    try {
      T obj = clazz.getConstructor().newInstance();
      for (DocField docField : fields) {
        docField.inject(obj, solrDoc);
      }
      return obj;
    } catch (Exception e) {
      throw new BindingException("Could not instantiate object of " + clazz, e);
    }
  }

  public SolrInputDocument toSolrInputDocument(Object obj) {
    List<DocField> fields = getDocFields(obj.getClass());
    if (fields.isEmpty()) {
      throw new BindingException("class: " + obj.getClass() + " does not define any fields.");
    }

    SolrInputDocument doc = new SolrInputDocument();
    for (DocField field : fields) {
      if (field.dynamicFieldNamePatternMatcher != null
          && field.get(obj) != null
          && field.isContainedInMap) {
        @SuppressWarnings({"unchecked"})
        Map<String, Object> mapValue = (Map<String, Object>) field.get(obj);

        for (Map.Entry<String, Object> e : mapValue.entrySet()) {
          doc.setField(e.getKey(), e.getValue());
        }
      } else {
        if (field.child != null) {
          addChild(obj, field, doc);
        } else {
          doc.setField(field.name, field.get(obj));
        }
      }
    }
    return doc;
  }

  private void addChild(Object obj, DocField field, SolrInputDocument doc) {
    Object val = field.get(obj);
    if (val == null) return;
    if (val instanceof Collection) {
      Collection<?> collection = (Collection<?>) val;
      for (Object o : collection) {
        SolrInputDocument child = toSolrInputDocument(o);
        doc.addChildDocument(child);
      }
    } else if (val.getClass().isArray()) {
      Object[] objs = (Object[]) val;
      for (Object o : objs) doc.addChildDocument(toSolrInputDocument(o));
    } else {
      doc.addChildDocument(toSolrInputDocument(val));
    }
  }

  private List<DocField> getDocFields(Class<?> clazz) {
    List<DocField> fields = infocache.get(clazz);
    if (fields == null) {
      synchronized (infocache) {
        infocache.put(clazz, fields = collectInfo(clazz));
      }
    }
    return fields;
  }

  @SuppressForbidden(reason = "Needs access to possibly private @Field annotated fields/methods")
  private List<DocField> collectInfo(Class<?> clazz) {
    List<DocField> fields = new ArrayList<>();
    Class<?> superClazz = clazz;
    List<AccessibleObject> members = new ArrayList<>();

    while (superClazz != null && superClazz != Object.class) {
      members.addAll(Arrays.asList(superClazz.getDeclaredFields()));
      members.addAll(Arrays.asList(superClazz.getDeclaredMethods()));
      superClazz = superClazz.getSuperclass();
    }
    boolean childFieldFound = false;
    for (AccessibleObject member : members) {
      if (member.isAnnotationPresent(Field.class)) {
        AccessController.doPrivileged(
            (PrivilegedAction<Void>)
                () -> {
                  member.setAccessible(true);
                  return null;
                });
        DocField df = new DocField(member);
        if (df.child != null) {
          if (childFieldFound)
            throw new BindingException(
                clazz.getName() + " cannot have more than one Field with child=true");
          childFieldFound = true;
        }
        fields.add(df);
      }
    }
    return fields;
  }

  private class DocField {
    private Field annotation;
    private String name;
    private java.lang.reflect.Field field;
    private Method setter;
    private Method getter;
    private Class<?> type;
    private boolean isArray;
    private boolean isList;
    private List<DocField> child;

    /*
     * dynamic fields may use a Map based data structure to bind a given field.
     * if a mapping is done using, "Map<String, List<String>> foo", <code>isContainedInMap</code>
     * is set to <code>TRUE</code> as well as <code>isList</code> is set to <code>TRUE</code>
     */
    private boolean isContainedInMap;
    private Pattern dynamicFieldNamePatternMatcher;

    public DocField(AccessibleObject member) {
      if (member instanceof java.lang.reflect.Field) {
        field = (java.lang.reflect.Field) member;
      } else {
        setter = (Method) member;
      }
      annotation = member.getAnnotation(Field.class);
      storeName(annotation);
      storeType();

      // Look for a matching getter
      if (setter != null) {
        String gname = setter.getName();
        if (gname.startsWith("set")) {
          gname = "get" + gname.substring(3);
          try {
            getter = setter.getDeclaringClass().getMethod(gname, (Class[]) null);
          } catch (Exception ex) {
            // no getter -- don't worry about it...
            if (type == Boolean.class) {
              gname = "is" + setter.getName().substring(3);
              try {
                getter = setter.getDeclaringClass().getMethod(gname, (Class[]) null);
              } catch (Exception ex2) {
                // no getter -- don't worry about it...
              }
            }
          }
        }
      }
    }

    private void storeName(Field annotation) {
      if (annotation.value().equals(DEFAULT)) {
        if (field != null) {
          name = field.getName();
        } else {
          String setterName = setter.getName();
          if (setterName.startsWith("set") && setterName.length() > 3) {
            name = setterName.substring(3, 4).toLowerCase(Locale.ROOT) + setterName.substring(4);
          } else {
            name = setter.getName();
          }
        }
      } else if (annotation.value().indexOf('*') >= 0) {
        // dynamic fields are annotated as @Field("categories_*")
        // if the field was annotated as a dynamic field, convert the name into a pattern
        // the wildcard (*) is supposed to be either a prefix or a suffix, hence the use of
        // replaceFirst
        name = annotation.value().replaceFirst("\\*", "\\.*");
        dynamicFieldNamePatternMatcher = Pattern.compile("^" + name + "$");
      } else {
        name = annotation.value();
      }
    }

    private void storeType() {
      if (field != null) {
        type = field.getType();
      } else {
        Class<?>[] params = setter.getParameterTypes();
        if (params.length != 1) {
          throw new BindingException(
              "Invalid setter method ("
                  + setter
                  + "). A setter must have one and only one parameter but we found "
                  + params.length
                  + " parameters.");
        }
        type = params[0];
      }

      if (type == Collection.class || type == List.class || type == ArrayList.class) {
        isList = true;
        if (annotation.child()) {
          populateChild(field.getGenericType());
        } else {
          type = Object.class;
        }
      } else if (type == byte[].class) {
        // no op
      } else if (type.isArray()) {
        isArray = true;
        if (annotation.child()) {
          populateChild(type.getComponentType());
        } else {
          type = type.getComponentType();
        }
      } else if (type == Map.class
          || type == HashMap.class) { // corresponding to the support for dynamicFields
        if (annotation.child())
          throw new BindingException("Map should is not a valid type for a child document");
        isContainedInMap = true;
        // assigned a default type
        type = Object.class;
        if (field != null) {
          if (field.getGenericType() instanceof ParameterizedType) {
            // check what are the generic values
            ParameterizedType parameterizedType = (ParameterizedType) field.getGenericType();
            Type[] types = parameterizedType.getActualTypeArguments();
            if (types != null && types.length == 2 && types[0] == String.class) {
              // the key should always be String
              // Raw and primitive types
              if (types[1] instanceof Class) {
                // the value could be multivalued then it is a List, Collection, ArrayList
                if (types[1] == Collection.class
                    || types[1] == List.class
                    || types[1] == ArrayList.class) {
                  type = Object.class;
                  isList = true;
                } else {
                  // else assume it is a primitive and put in the source type itself
                  type = (Class) types[1];
                }
              } else if (types[1]
                  instanceof
                  ParameterizedType) { // Of all the Parameterized types, only List is supported
                Type rawType = ((ParameterizedType) types[1]).getRawType();
                if (rawType == Collection.class
                    || rawType == List.class
                    || rawType == ArrayList.class) {
                  type = Object.class;
                  isList = true;
                }
              } else if (types[1] instanceof GenericArrayType) { // Array types
                type = (Class) ((GenericArrayType) types[1]).getGenericComponentType();
                isArray = true;
              } else { // Throw an Exception if types are not known
                throw new BindingException(
                    "Allowed type for values of mapping a dynamicField are : "
                        + "Object, Object[] and List");
              }
            }
          }
        }
      } else {
        if (annotation.child()) {
          populateChild(type);
        }
      }
    }

    private void populateChild(Type typ) {
      if (typ == null) {
        throw new RuntimeException(
            "no type information available for" + (field == null ? setter : field));
      }
      if (typ.getClass() == Class.class) { // of type class
        type = (Class) typ;
      } else if (typ instanceof ParameterizedType) {
        try {
          type = Class.forName(((ParameterizedType) typ).getActualTypeArguments()[0].getTypeName());
        } catch (ClassNotFoundException e) {
          throw new BindingException(
              "Invalid type information available for" + (field == null ? setter : field));
        }
      } else {
        throw new BindingException(
            "Invalid type information available for" + (field == null ? setter : field));
      }
      child = getDocFields(type);
    }

    /**
     * Called by the {@link #inject} method to read the value(s) for a field This method supports
     * reading of all "matching" fieldName's in the {@link SolrDocument} Returns {@link
     * SolrDocument#getFieldValue} for regular fields, and {@code Map<String, List<Object>>} for a
     * dynamic field. The key is all matching fieldName's.
     */
    private Object getFieldValue(SolrDocument solrDocument) {
      if (child != null) {
        List<SolrDocument> children = solrDocument.getChildDocuments();
        if (children == null || children.isEmpty()) return null;
        if (isList) {
          ArrayList<Object> list = new ArrayList<>(children.size());
          for (SolrDocument c : children) {
            list.add(getBean(type, child, c));
          }
          return list;
        } else if (isArray) {
          Object[] arr = (Object[]) Array.newInstance(type, children.size());
          for (int i = 0; i < children.size(); i++) {
            arr[i] = getBean(type, child, children.get(i));
          }
          return arr;

        } else {
          return getBean(type, child, children.get(0));
        }
      }
      Object fieldValue = solrDocument.getFieldValue(name);
      if (fieldValue != null) {
        // this is not a dynamic field. so return the value
        return fieldValue;
      }

      if (dynamicFieldNamePatternMatcher == null) {
        return null;
      }

      // reading dynamic field values
      Map<String, Object> allValuesMap = null;
      List<Object> allValuesList = null;
      if (isContainedInMap) {
        allValuesMap = new HashMap<>();
      } else {
        allValuesList = new ArrayList<>();
      }

      for (String field : solrDocument.getFieldNames()) {
        if (dynamicFieldNamePatternMatcher.matcher(field).find()) {
          Object val = solrDocument.getFieldValue(field);
          if (val == null) {
            continue;
          }

          if (isContainedInMap) {
            if (isList) {
              if (!(val instanceof List)) {
                List<Object> al = new ArrayList<>();
                al.add(val);
                val = al;
              }
            } else if (isArray) {
              if (!(val instanceof List)) {
                Object[] arr = (Object[]) Array.newInstance(type, 1);
                arr[0] = val;
                val = arr;
              } else {
                val = Array.newInstance(type, ((List) val).size());
              }
            }
            allValuesMap.put(field, val);
          } else {
            if (val instanceof Collection) {
              allValuesList.addAll((Collection<?>) val);
            } else {
              allValuesList.add(val);
            }
          }
        }
      }
      if (isContainedInMap) {
        return allValuesMap.isEmpty() ? null : allValuesMap;
      } else {
        return allValuesList.isEmpty() ? null : allValuesList;
      }
    }

    <T> void inject(T obj, SolrDocument sdoc) {
      Object val = getFieldValue(sdoc);
      if (val == null) {
        return;
      }

      if (isArray && !isContainedInMap) {
        List<?> list;
        if (val.getClass().isArray()) {
          set(obj, val);
          return;
        } else if (val instanceof List) {
          list = (List<?>) val;
        } else {
          list = Collections.singletonList(val);
        }
        set(obj, list.toArray((Object[]) Array.newInstance(type, list.size())));
      } else if (isList && !isContainedInMap) {
        if (!(val instanceof List)) {
          List<Object> list = new ArrayList<>();
          list.add(val);
          val = list;
        }
        set(obj, val);
      } else if (isContainedInMap) {
        if (val instanceof Map) {
          set(obj, val);
        }
      } else {
        set(obj, val);
      }
    }

    private void set(Object obj, Object v) {
      if (v != null && type == ByteBuffer.class && v.getClass() == byte[].class) {
        v = ByteBuffer.wrap((byte[]) v);
      }
      try {
        if (field != null) {
          field.set(obj, v);
        } else if (setter != null) {
          setter.invoke(obj, v);
        }
      } catch (Exception e) {
        throw new BindingException(
            "Exception while setting value : " + v + " on " + (field != null ? field : setter), e);
      }
    }

    public Object get(final Object obj) {
      if (field != null) {
        try {
          return field.get(obj);
        } catch (Exception e) {
          throw new BindingException("Exception while getting value: " + field, e);
        }
      } else if (getter == null) {
        throw new BindingException(
            "Missing getter for field: "
                + name
                + " -- You can only call the 'get' for fields that have a field of 'get' method");
      }

      try {
        return getter.invoke(obj, (Object[]) null);
      } catch (Exception e) {
        throw new BindingException("Exception while getting value: " + getter, e);
      }
    }
  }

  public static final String DEFAULT = "#default";
}
