/*
 * Copyright 2010 Google Inc.
 *
 * 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.web.bindery.requestfactory.server;

import com.google.gwt.user.server.Base64Utils;
import com.google.web.bindery.autobean.shared.AutoBean;
import com.google.web.bindery.autobean.shared.AutoBeanCodex;
import com.google.web.bindery.autobean.shared.AutoBeanUtils;
import com.google.web.bindery.autobean.shared.AutoBeanVisitor;
import com.google.web.bindery.autobean.shared.Splittable;
import com.google.web.bindery.autobean.shared.ValueCodex;
import com.google.web.bindery.autobean.vm.AutoBeanFactorySource;
import com.google.web.bindery.autobean.vm.Configuration;
import com.google.web.bindery.autobean.vm.impl.TypeUtils;
import com.google.web.bindery.requestfactory.shared.BaseProxy;
import com.google.web.bindery.requestfactory.shared.EntityProxyId;
import com.google.web.bindery.requestfactory.shared.InstanceRequest;
import com.google.web.bindery.requestfactory.shared.Request;
import com.google.web.bindery.requestfactory.shared.RequestContext;
import com.google.web.bindery.requestfactory.shared.ServerFailure;
import com.google.web.bindery.requestfactory.shared.WriteOperation;
import com.google.web.bindery.requestfactory.shared.impl.BaseProxyCategory;
import com.google.web.bindery.requestfactory.shared.impl.Constants;
import com.google.web.bindery.requestfactory.shared.impl.EntityCodex;
import com.google.web.bindery.requestfactory.shared.impl.EntityProxyCategory;
import com.google.web.bindery.requestfactory.shared.impl.SimpleProxyId;
import com.google.web.bindery.requestfactory.shared.impl.ValueProxyCategory;
import com.google.web.bindery.requestfactory.shared.messages.IdMessage;
import com.google.web.bindery.requestfactory.shared.messages.IdMessage.Strength;
import com.google.web.bindery.requestfactory.shared.messages.InvocationMessage;
import com.google.web.bindery.requestfactory.shared.messages.MessageFactory;
import com.google.web.bindery.requestfactory.shared.messages.OperationMessage;
import com.google.web.bindery.requestfactory.shared.messages.RequestMessage;
import com.google.web.bindery.requestfactory.shared.messages.ResponseMessage;
import com.google.web.bindery.requestfactory.shared.messages.ServerFailureMessage;
import com.google.web.bindery.requestfactory.shared.messages.ViolationMessage;

import java.io.UnsupportedEncodingException;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;

import javax.validation.ConstraintViolation;

/**
 * Processes request payloads from a RequestFactory client. This implementation
 * is stateless. A single instance may be reused and is thread-safe.
 */
public class SimpleRequestProcessor {
  /**
   * This parameterization is so long, it improves readability to have a
   * specific type.
   * <p>
   * FIXME: IDs used as keys in this map can be mutated (turning an ephemeral
   * ID to a persisted ID in Resolver#resolveClientProxy) in a way that can
   * change their hashCode value and equals behavior, therefore breaking the
   * Map contract. We should find a way to only put immutable IDs here, or
   * change SimpleProxyId so that its hashCode value and equals behavior don't
   * change, or possibly remove and re-add the entry when the ID is modified
   * (as this is something entirely under our control).
   */
  @SuppressWarnings("serial")
  static class IdToEntityMap extends HashMap<SimpleProxyId<?>, AutoBean<? extends BaseProxy>> {
  }

  /**
   * Allows the creation of properly-configured AutoBeans without having to
   * create an AutoBeanFactory with the desired annotations.
   */
  static final Configuration CONFIGURATION = new Configuration.Builder().setCategories(
      EntityProxyCategory.class, ValueProxyCategory.class, BaseProxyCategory.class).setNoWrap(
      EntityProxyId.class).build();

  /**
   * Vends message objects.
   */
  static final MessageFactory FACTORY = AutoBeanFactorySource.create(MessageFactory.class);

  static String fromBase64(String encoded) {
    try {
      return new String(Base64Utils.fromBase64(encoded), "UTF-8");
    } catch (UnsupportedEncodingException e) {
      throw new UnexpectedException(e);
    }
  }

  static String toBase64(String data) {
    try {
      return Base64Utils.toBase64(data.getBytes("UTF-8"));
    } catch (UnsupportedEncodingException e) {
      throw new UnexpectedException(e);
    }
  }

  private ExceptionHandler exceptionHandler = new DefaultExceptionHandler();
  private final ServiceLayer service;

  public SimpleRequestProcessor(ServiceLayer serviceLayer) {
    this.service = serviceLayer;
  }

  /**
   * Process a payload sent by a RequestFactory client.
   *
   * @param payload the payload sent by the client
   * @return a payload to return to the client
   */
  public String process(String payload) {
    RequestMessage req = AutoBeanCodex.decode(FACTORY, RequestMessage.class, payload).as();
    AutoBean<ResponseMessage> responseBean = FACTORY.response();
    try {
      process(req, responseBean.as());
    } catch (ReportableException e) {
      // Create a new response envelope, since the state is unknown
      responseBean = FACTORY.response();
      responseBean.as().setGeneralFailure(createFailureMessage(e).as());
    }
    // Return a JSON-formatted payload
    return AutoBeanCodex.encode(responseBean).getPayload();
  }

  public void setExceptionHandler(ExceptionHandler exceptionHandler) {
    this.exceptionHandler = exceptionHandler;
  }

  /**
   * Encode a list of objects into a self-contained message that can be used for
   * out-of-band communication.
   */
  <T> Splittable createOobMessage(List<T> domainValues) {
    RequestState state = new RequestState(service);

    List<Splittable> encodedValues = new ArrayList<Splittable>(domainValues.size());
    for (T domainValue : domainValues) {
      Object clientValue;
      if (domainValue == null) {
        clientValue = null;
      } else {
        Class<?> clientType =
            service.resolveClientType(domainValue.getClass(), BaseProxy.class, true);
        clientValue =
            state.getResolver().resolveClientValue(domainValue, clientType,
                Collections.<String> emptySet());
      }
      encodedValues.add(EntityCodex.encode(state, clientValue));
    }

    IdToEntityMap map = new IdToEntityMap();
    map.putAll(state.beans);
    List<OperationMessage> operations = new ArrayList<OperationMessage>();
    createReturnOperations(operations, state, map);

    InvocationMessage invocation = FACTORY.invocation().as();
    invocation.setParameters(encodedValues);

    AutoBean<RequestMessage> bean = FACTORY.request();
    RequestMessage resp = bean.as();
    resp.setInvocations(Collections.singletonList(invocation));
    resp.setOperations(operations);
    return AutoBeanCodex.encode(bean);
  }

  /**
   * Decode an out-of-band message.
   */
  <T> List<T> decodeOobMessage(Class<T> domainClass, Splittable payload) {
    Class<?> proxyType = service.resolveClientType(domainClass, BaseProxy.class, true);
    RequestState state = new RequestState(service);
    RequestMessage message = AutoBeanCodex.decode(FACTORY, RequestMessage.class, payload).as();
    processOperationMessages(state, message);
    List<Object> decoded =
        decodeInvocationArguments(state, message.getInvocations().get(0).getParameters(),
            new Class<?>[] {proxyType}, new Type[] {domainClass});

    @SuppressWarnings("unchecked")
    List<T> toReturn = (List<T>) decoded;
    return toReturn;
  }

  /**
   * Main processing method.
   */
  void process(RequestMessage req, ResponseMessage resp) {
    final RequestState source = new RequestState(service);

    // Make sure the RequestFactory is valid
    String requestFactoryToken = req.getRequestFactory();
    if (requestFactoryToken == null) {
      // Tell old clients to go away
      throw new ReportableException("The client payload version is out of sync with the server");
    }
    service.resolveRequestFactory(requestFactoryToken);

    // Apply operations
    processOperationMessages(source, req);

    // Validate entities
    List<ViolationMessage> errorMessages = validateEntities(source);

    if (!errorMessages.isEmpty()) {
      resp.setViolations(errorMessages);
      return;
    }

    RequestState returnState = new RequestState(source);

    // Invoke methods
    List<Splittable> invocationResults = new ArrayList<Splittable>();
    List<Boolean> invocationSuccess = new ArrayList<Boolean>();
    processInvocationMessages(source, req, invocationResults, invocationSuccess, returnState);

    // Store return objects
    List<OperationMessage> operations = new ArrayList<OperationMessage>();
    IdToEntityMap toProcess = new IdToEntityMap();
    toProcess.putAll(source.beans);
    toProcess.putAll(returnState.beans);
    createReturnOperations(operations, returnState, toProcess);

    assert invocationResults.size() == invocationSuccess.size();
    if (!invocationResults.isEmpty()) {
      resp.setInvocationResults(invocationResults);
      resp.setStatusCodes(invocationSuccess);
    }
    if (!operations.isEmpty()) {
      resp.setOperations(operations);
    }
  }

  private AutoBean<ServerFailureMessage> createFailureMessage(ReportableException e) {
    ServerFailure failure =
        exceptionHandler.createServerFailure(e.getCause() == null ? e : e.getCause());
    AutoBean<ServerFailureMessage> bean = FACTORY.failure();
    ServerFailureMessage msg = bean.as();
    msg.setExceptionType(failure.getExceptionType());
    msg.setMessage(failure.getMessage());
    msg.setStackTrace(failure.getStackTraceString());
    msg.setFatal(failure.isFatal());
    return bean;
  }

  private void createReturnOperations(List<OperationMessage> operations, RequestState returnState,
      IdToEntityMap toProcess) {
    for (Map.Entry<SimpleProxyId<?>, AutoBean<? extends BaseProxy>> entry : toProcess.entrySet()) {
      SimpleProxyId<?> id = entry.getKey();

      AutoBean<? extends BaseProxy> bean = entry.getValue();
      Object domainObject = bean.getTag(Constants.DOMAIN_OBJECT);
      WriteOperation writeOperation;

      if (id.isEphemeral() && returnState.isEntityType(id.getProxyClass())) {
        // See if the entity has been persisted in the meantime
        returnState.getResolver().resolveClientValue(domainObject, id.getProxyClass(),
            Collections.<String> emptySet());
      }

      if (id.isEphemeral() || id.isSynthetic() || domainObject == null) {
        // If the object isn't persistent, there's no reason to send an update
        writeOperation = null;
      } else if (!service.isLive(domainObject)) {
        writeOperation = WriteOperation.DELETE;
      } else if (id.wasEphemeral()) {
        writeOperation = WriteOperation.PERSIST;
      } else {
        writeOperation = WriteOperation.UPDATE;
      }

      Splittable version = null;
      if (writeOperation == WriteOperation.PERSIST || writeOperation == WriteOperation.UPDATE) {
        /*
         * If we're sending an operation, the domain object must be persistent.
         * This means that it must also have a non-null version.
         */
        Object domainVersion = service.getVersion(domainObject);
        if (domainVersion == null) {
          throw new UnexpectedException("The persisted entity with id "
              + service.getId(domainObject) + " has a null version", null);
        }
        version = returnState.flatten(domainVersion);
      }

      boolean inResponse = bean.getTag(Constants.IN_RESPONSE) != null;

      /*
       * Don't send any data back to the client for an update on an object that
       * isn't part of the response payload when the client's version matches
       * the domain version.
       */
      if (WriteOperation.UPDATE.equals(writeOperation) && !inResponse) {
        String previousVersion = bean.<String> getTag(Constants.VERSION_PROPERTY_B64);
        if (version != null && previousVersion != null
            && version.equals(fromBase64(previousVersion))) {
          continue;
        }
      }

      OperationMessage op = FACTORY.operation().as();

      /*
       * Send a client id if the id is ephemeral or was previously associated
       * with a client id.
       */
      if (id.wasEphemeral()) {
        op.setClientId(id.getClientId());
      }

      op.setOperation(writeOperation);

      // Only send properties for entities that are part of the return graph
      if (inResponse) {
        Map<String, Splittable> propertyMap = new LinkedHashMap<String, Splittable>();
        // Add all non-null properties to the serialized form
        Map<String, Object> diff = AutoBeanUtils.getAllProperties(bean);
        for (Map.Entry<String, Object> d : diff.entrySet()) {
          Object value = d.getValue();
          if (value != null) {
            propertyMap.put(d.getKey(), EntityCodex.encode(returnState, value));
          }
        }
        op.setPropertyMap(propertyMap);
      }

      if (!id.isEphemeral() && !id.isSynthetic()) {
        // Send the server address only for persistent objects
        op.setServerId(toBase64(id.getServerId()));
      }

      if (id.isSynthetic()) {
        op.setStrength(Strength.SYNTHETIC);
        op.setSyntheticId(id.getSyntheticId());
      } else if (id.isEphemeral()) {
        op.setStrength(Strength.EPHEMERAL);
      }

      op.setTypeToken(service.resolveTypeToken(id.getProxyClass()));
      if (version != null) {
        op.setVersion(toBase64(version.getPayload()));
      }

      operations.add(op);
    }
  }

  /**
   * Decode the arguments to pass into the domain method. If the domain method
   * is not static, the instance object will be in the 0th position.
   */
  private List<Object> decodeInvocationArguments(RequestState source, InvocationMessage invocation,
      Method contextMethod) {
    boolean isStatic = Request.class.isAssignableFrom(contextMethod.getReturnType());
    int baseLength = contextMethod.getParameterTypes().length;
    int length = baseLength + (isStatic ? 0 : 1);
    int offset = isStatic ? 0 : 1;
    Class<?>[] contextArgs = new Class<?>[length];
    Type[] genericArgs = new Type[length];

    if (!isStatic) {
      genericArgs[0] =
          TypeUtils.getSingleParameterization(InstanceRequest.class, contextMethod
              .getGenericReturnType());
      contextArgs[0] = TypeUtils.ensureBaseType(genericArgs[0]);
    }
    System.arraycopy(contextMethod.getParameterTypes(), 0, contextArgs, offset, baseLength);
    System.arraycopy(contextMethod.getGenericParameterTypes(), 0, genericArgs, offset, baseLength);

    List<Object> args =
        decodeInvocationArguments(source, invocation.getParameters(), contextArgs, genericArgs);
    return args;
  }

  /**
   * Handles instance invocations as the instance at the 0th parameter.
   */
  private List<Object> decodeInvocationArguments(RequestState source, List<Splittable> parameters,
      Class<?>[] contextArgs, Type[] genericArgs) {
    if (parameters == null) {
      // Can't return Collections.emptyList() because this must be mutable
      return new ArrayList<Object>();
    }

    assert parameters.size() == contextArgs.length;
    List<Object> args = new ArrayList<Object>(contextArgs.length);
    for (int i = 0, j = contextArgs.length; i < j; i++) {
      Class<?> type = contextArgs[i];
      Class<?> elementType = null;
      Splittable split;
      if (Collection.class.isAssignableFrom(type)) {
        elementType =
            TypeUtils.ensureBaseType(TypeUtils.getSingleParameterization(Collection.class,
                genericArgs[i]));
        split = parameters.get(i);
      } else {
        split = parameters.get(i);
      }
      Object arg = EntityCodex.decode(source, type, elementType, split);
      arg =
          source.getResolver().resolveDomainValue(arg, !EntityProxyId.class.equals(contextArgs[i]));
      args.add(arg);
    }

    return args;
  }

  private void processInvocationMessages(RequestState state, RequestMessage req,
      List<Splittable> results, List<Boolean> success, RequestState returnState) {
    List<InvocationMessage> invocations = req.getInvocations();
    if (invocations == null) {
      // No method invocations which can happen via RequestContext.fire()
      return;
    }
    List<Method> contextMethods = new ArrayList<Method>(invocations.size());
    List<Object> invocationResults = new ArrayList<Object>(invocations.size());
    Map<Object, SortedSet<String>> allPropertyRefs = new HashMap<Object, SortedSet<String>>();
    for (InvocationMessage invocation : invocations) {
      Object domainReturnValue;
      boolean ok;
      try {
        // Find the Method
        String operation = invocation.getOperation();
        Method contextMethod = service.resolveRequestContextMethod(operation);
        if (contextMethod == null) {
          throw new UnexpectedException("Cannot resolve operation " + invocation.getOperation(),
              null);
        }
        contextMethods.add(contextMethod);
        Method domainMethod = service.resolveDomainMethod(operation);
        if (domainMethod == null) {
          throw new UnexpectedException(
              "Cannot resolve domain method " + invocation.getOperation(), null);
        }

        // Compute the arguments
        List<Object> args = decodeInvocationArguments(state, invocation, contextMethod);
        // Possibly use a ServiceLocator
        if (service.requiresServiceLocator(contextMethod, domainMethod)) {
          Class<? extends RequestContext> requestContext = service.resolveRequestContext(operation);
          Object serviceInstance = service.createServiceInstance(requestContext);
          args.add(0, serviceInstance);
        }
        // Invoke it
        domainReturnValue = service.invoke(domainMethod, args.toArray());
        if (invocation.getPropertyRefs() != null) {
          SortedSet<String> paths = allPropertyRefs.get(domainReturnValue);
          if (paths == null) {
            paths = new TreeSet<String>();
            allPropertyRefs.put(domainReturnValue, paths);
          }
          paths.addAll(invocation.getPropertyRefs());
        }
        ok = true;
      } catch (ReportableException e) {
        domainReturnValue = AutoBeanCodex.encode(createFailureMessage(e));
        ok = false;
      }
      invocationResults.add(domainReturnValue);
      success.add(ok);
    }
    Iterator<Method> contextMethodIt = contextMethods.iterator();
    Iterator<Object> objects = invocationResults.iterator();
    Iterator<Boolean> successes = success.iterator();
    while (successes.hasNext()) {
      assert contextMethodIt.hasNext();
      assert objects.hasNext();
      Method contextMethod = contextMethodIt.next();
      Object returnValue = objects.next();
      if (successes.next()) {
        // Convert domain object to client object
        Type requestReturnType = service.getRequestReturnType(contextMethod);
        returnValue =
            state.getResolver().resolveClientValue(returnValue, requestReturnType,
                allPropertyRefs.get(returnValue));

        // Convert the client object to a string
        results.add(EntityCodex.encode(returnState, returnValue));
      } else {
        results.add((Splittable) returnValue);
      }
    }
  }

  private void processOperationMessages(final RequestState state, RequestMessage req) {
    List<OperationMessage> operations = req.getOperations();
    if (operations == null) {
      return;
    }

    List<AutoBean<? extends BaseProxy>> beans = state.getBeansForPayload(operations);
    assert operations.size() == beans.size();

    Iterator<OperationMessage> itOp = operations.iterator();
    for (AutoBean<? extends BaseProxy> bean : beans) {
      OperationMessage operation = itOp.next();
      // Save the client's version information to reduce payload size later
      bean.setTag(Constants.VERSION_PROPERTY_B64, operation.getVersion());

      // Load the domain object with properties, if it exists
      final Object domain = bean.getTag(Constants.DOMAIN_OBJECT);
      if (domain != null) {
        // Apply any property updates
        final Map<String, Splittable> flatValueMap = operation.getPropertyMap();
        if (flatValueMap != null) {
          bean.accept(new AutoBeanVisitor() {
            @Override
            public boolean visitReferenceProperty(String propertyName, AutoBean<?> value,
                PropertyContext ctx) {
              // containsKey to distinguish null from unknown
              if (flatValueMap.containsKey(propertyName)) {
                Object resolved = null;
                // The null check on getKeyType() is necessary as some of the given PropertyContext's
                // implement both MapPropertyContext and CollectionPropertyContext.
                if (ctx.getType() == Map.class) {
                    MapPropertyContext mapCtx = (MapPropertyContext) ctx;
                    Class<?> keyType = mapCtx.getKeyType();
                    Class<?> valueType = mapCtx.getValueType();
                    Object newValue =
                        EntityCodex.decode(state, mapCtx.getType(), keyType,
                            valueType, flatValueMap.get(propertyName));
                    resolved = state.getResolver().resolveDomainValue(newValue, false);
                } else {
                  Class<?> elementType =
                      ctx instanceof CollectionPropertyContext ? ((CollectionPropertyContext) ctx)
                          .getElementType() : null;
                  Object newValue =
                      EntityCodex.decode(state, ctx.getType(), elementType, flatValueMap
                          .get(propertyName));
                  resolved = state.getResolver().resolveDomainValue(newValue, false);
                }
                service.setProperty(domain, propertyName,
                service.resolveDomainClass(ctx.getType()), resolved);
              }
              return false;
            }

            @Override
            public boolean visitValueProperty(String propertyName, Object value, PropertyContext ctx) {
              if (flatValueMap.containsKey(propertyName)) {
                Splittable split = flatValueMap.get(propertyName);
                Object newValue = ValueCodex.decode(ctx.getType(), split);
                Object resolved = state.getResolver().resolveDomainValue(newValue, false);
                service.setProperty(domain, propertyName, ctx.getType(), resolved);
              }
              return false;
            }
          });
        }
      }
    }
  }

  /**
   * Validate all of the entities referenced in a RequestState.
   */
  private List<ViolationMessage> validateEntities(RequestState source) {
    List<ViolationMessage> errorMessages = new ArrayList<ViolationMessage>();
    for (Map.Entry<SimpleProxyId<?>, AutoBean<? extends BaseProxy>> entry : source.beans.entrySet()) {
      AutoBean<? extends BaseProxy> bean = entry.getValue();
      Object domainObject = bean.getTag(Constants.DOMAIN_OBJECT);

      // The object could have been deleted
      if (domainObject != null) {
        Set<ConstraintViolation<Object>> errors = service.validate(domainObject);
        if (errors != null && !errors.isEmpty()) {
          SimpleProxyId<?> id = entry.getKey();
          for (ConstraintViolation<Object> error : errors) {
            // Construct an ID that represents domainObject
            IdMessage rootId = FACTORY.id().as();
            rootId.setClientId(id.getClientId());
            rootId.setTypeToken(service.resolveTypeToken(id.getProxyClass()));
            if (id.isEphemeral()) {
              rootId.setStrength(Strength.EPHEMERAL);
            } else {
              rootId.setServerId(toBase64(id.getServerId()));
            }

            // If possible, also include the id of the leaf bean
            IdMessage leafId = null;
            if (error.getLeafBean() != null) {
              SimpleProxyId<?> stableId = source.getStableId(error.getLeafBean());
              if (stableId != null) {
                leafId = FACTORY.id().as();
                leafId.setClientId(stableId.getClientId());
                leafId.setTypeToken(service.resolveTypeToken(stableId.getProxyClass()));
                if (stableId.isEphemeral()) {
                  leafId.setStrength(Strength.EPHEMERAL);
                } else {
                  leafId.setServerId(toBase64(stableId.getServerId()));
                }
              }
            }

            ViolationMessage message = FACTORY.violation().as();
            message.setLeafBeanId(leafId);
            message.setMessage(error.getMessage());
            message.setMessageTemplate(error.getMessageTemplate());
            message.setPath(error.getPropertyPath().toString());
            message.setRootBeanId(rootId);
            errorMessages.add(message);
          }
        }
      }
    }
    return errorMessages;
  }
}
