/* See LICENSE for licensing and NOTICE for copyright. */
package org.ldaptive.beans.spring;

import java.lang.reflect.Array;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import org.ldaptive.beans.Attribute;
import org.ldaptive.beans.AttributeValueMutator;
import org.ldaptive.transcode.ValueTranscoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;

/**
 * Attribute mutator that uses a SPEL expression and evaluation context.
 *
 * @author  Middleware Services
 */
public class SpelAttributeValueMutator implements AttributeValueMutator
{

  /** Logger for this class. */
  protected final Logger logger = LoggerFactory.getLogger(getClass());

  /** Attribute containing the SPEL expression. */
  private final Attribute attribute;

  /** SPEL expression to access values. */
  private final Expression expression;

  /** Evaluation context. */
  private final EvaluationContext evaluationContext;

  /** Custom transcoder for this attribute. */
  private final ValueTranscoder transcoder;


  /**
   * Creates a new spel attribute value mutator.
   *
   * @param  attr  containing the SPEL configuration
   * @param  context  containing the values
   */
  public SpelAttributeValueMutator(final Attribute attr, final EvaluationContext context)
  {
    attribute = attr;

    final ExpressionParser parser = new SpelExpressionParser();
    expression = parser.parseExpression(attribute.property().length() > 0 ? attribute.property() : attribute.name());
    evaluationContext = context;
    if ("".equals(attribute.transcoder())) {
      transcoder = null;
    } else {
      transcoder = parser.parseExpression(attribute.transcoder()).getValue(ValueTranscoder.class);
    }
  }


  @Override
  public String getName()
  {
    return attribute.name();
  }


  @Override
  public boolean isBinary()
  {
    return attribute.binary();
  }


  @Override
  public Collection<String> getStringValues(final Object object)
  {
    return getValues(object, String.class);
  }


  @Override
  public Collection<byte[]> getBinaryValues(final Object object)
  {
    return getValues(object, byte[].class);
  }


  /**
   * Uses the configured expression and evaluation context to retrieve values from the supplied object. Values are the
   * placed in a collection and returned.
   *
   * @param  <T>  either String or byte[]
   * @param  object  to get values from
   * @param  type  of objects to place in the collection
   *
   * @return  values in the supplied object
   */
  protected <T> Collection<T> getValues(final Object object, final Class<T> type)
  {
    Collection<T> values = null;
    final Object converted = expression.getValue(evaluationContext, object);
    if (converted != null) {
      if (converted instanceof byte[] || converted instanceof char[]) {
        values = createCollection(List.class, 1);

        final T value = convertValue(converted, converted.getClass(), type);
        if (value != null) {
          values.add(value);
        }
      } else if (converted.getClass().isArray()) {
        final int length = Array.getLength(converted);
        values = createCollection(List.class, length);
        for (int i = 0; i < length; i++) {
          final Object o = Array.get(converted, i);
          if (o != null) {
            final T value = convertValue(o, o.getClass(), type);
            if (value != null) {
              values.add(value);
            }
          }
        }
      } else if (Collection.class.isAssignableFrom(converted.getClass())) {
        final Collection<?> col = (Collection<?>) converted;
        values = createCollection(converted.getClass(), col.size());
        for (Object o : col) {
          if (o != null) {
            final T value = convertValue(o, o.getClass(), type);
            if (value != null) {
              values.add(value);
            }
          }
        }
      } else {
        values = createCollection(List.class, 1);

        final T value = convertValue(converted, converted.getClass(), type);
        if (value != null) {
          values.add(value);
        }
      }
    }
    return values;
  }


  /**
   * Converts the supplied value to the target type. If a custom transcoder has been configured it is used. Otherwise,
   * the type converter from the evaluation context is used.
   *
   * @param  <T>  either String or byte[]
   * @param  value  to convert
   * @param  sourceType  to convert from
   * @param  targetType  to convert to
   *
   * @return  converted value
   */
  @SuppressWarnings("unchecked")
  protected <T> T convertValue(final Object value, final Class<?> sourceType, final Class<T> targetType)
  {
    final T converted;
    if (transcoder != null) {
      if (byte[].class == targetType) {
        converted = (T) transcoder.encodeBinaryValue(value);
      } else if (String.class == targetType) {
        converted = (T) transcoder.encodeStringValue(value);
      } else {
        throw new IllegalArgumentException("targetType must be either String.class or byte[].class");
      }
    } else {
      converted = (T) evaluationContext.getTypeConverter().convertValue(
        value,
        TypeDescriptor.valueOf(sourceType),
        TypeDescriptor.valueOf(targetType));
    }
    return converted;
  }


  @Override
  public void setStringValues(final Object object, final Collection<String> values)
  {
    setValues(object, values, String.class);
  }


  @Override
  public void setBinaryValues(final Object object, final Collection<byte[]> values)
  {
    setValues(object, values, byte[].class);
  }


  /**
   * Uses the configured expression and evaluation context to set values on the supplied object. If a custom transcoder
   * has been configured it is executed on the values before they are passed to the expression.
   *
   * @param  <T>  either String or byte[]
   * @param  object  to set values on
   * @param  values  to set
   * @param  type  of objects in the collection
   */
  protected <T> void setValues(final Object object, final Collection<T> values, final Class<T> type)
  {
    if (transcoder != null) {
      final Collection<Object> newValues = createCollection(values.getClass(), values.size());
      for (T t : values) {
        if (byte[].class == type) {
          newValues.add(transcoder.decodeBinaryValue((byte[]) t));
        } else if (String.class == type) {
          newValues.add(transcoder.decodeStringValue((String) t));
        } else {
          throw new IllegalArgumentException("type must be either String.class or byte[].class");
        }
      }
      expression.setValue(evaluationContext, object, newValues);
    } else {
      if (values != null && values.size() == 1) {
        expression.setValue(evaluationContext, object, values.iterator().next());
      } else {
        expression.setValue(evaluationContext, object, values);
      }
    }
  }


  @Override
  public String toString()
  {
    return new StringBuilder("[").append(
      getClass().getName()).append("@").append(hashCode()).append("::")
      .append("attribute=").append(attribute).append(", ")
      .append("expression=").append(expression).append(", ")
      .append("evaluationContext=").append(evaluationContext).append(", ")
      .append("transcoder=").append(transcoder).append("]").toString();
  }


  /**
   * Creates the best fit collection for the supplied type.
   *
   * @param  <T>  collection type
   * @param  type  of collection to create
   * @param  size  of the collection
   *
   * @return  collection
   */
  protected static <T> Collection<T> createCollection(final Class<?> type, final int size)
  {
    final Collection<T> c;
    if (List.class.isAssignableFrom(type)) {
      if (LinkedList.class.isAssignableFrom(type)) {
        c = new LinkedList<>();
      } else {
        c = new ArrayList<>(size);
      }
    } else if (Set.class.isAssignableFrom(type)) {
      c = new LinkedHashSet<>(size);
    } else {
      c = new ArrayList<>(size);
    }
    return c;
  }
}
