/*
 * 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.jclouds.rest.internal;

import static com.google.common.base.Functions.toStringFunction;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.base.Predicates.instanceOf;
import static com.google.common.collect.Collections2.filter;
import static com.google.common.collect.Iterables.concat;
import static com.google.common.collect.Iterables.get;
import static com.google.common.collect.Iterables.transform;
import static com.google.common.collect.Iterables.tryFind;
import static com.google.common.collect.Lists.newArrayList;
import static com.google.common.collect.Lists.newLinkedList;
import static com.google.common.collect.Multimaps.transformValues;
import static com.google.common.net.HttpHeaders.ACCEPT;
import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
import static com.google.common.net.HttpHeaders.HOST;
import static java.lang.String.format;
import static java.util.Arrays.asList;
import static org.jclouds.http.HttpUtils.filterOutContentHeaders;
import static org.jclouds.http.HttpUtils.tryFindHttpMethod;
import static org.jclouds.http.Uris.uriBuilder;
import static org.jclouds.io.Payloads.newPayload;
import static org.jclouds.reflect.Reflection2.getInvokableParameters;
import static org.jclouds.util.Strings2.replaceTokens;
import static org.jclouds.util.Strings2.urlEncode;

import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NoSuchElementException;
import java.util.Set;

import javax.annotation.Resource;
import javax.inject.Named;
import javax.ws.rs.Encoded;
import javax.ws.rs.FormParam;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;

import org.jclouds.Constants;
import org.jclouds.domain.Credentials;
import org.jclouds.http.HttpRequest;
import org.jclouds.http.HttpRequestFilter;
import org.jclouds.http.HttpUtils;
import org.jclouds.http.Uris.UriBuilder;
import org.jclouds.http.filters.ConnectionCloseHeader;
import org.jclouds.http.filters.StripExpectHeader;
import org.jclouds.http.options.HttpRequestOptions;
import org.jclouds.http.utils.QueryValue;
import org.jclouds.io.ContentMetadataCodec;
import org.jclouds.io.Payload;
import org.jclouds.io.PayloadEnclosing;
import org.jclouds.io.Payloads;
import org.jclouds.io.payloads.MultipartForm;
import org.jclouds.io.payloads.Part;
import org.jclouds.io.payloads.Part.PartOptions;
import org.jclouds.javax.annotation.Nullable;
import org.jclouds.location.Provider;
import org.jclouds.logging.Logger;
import org.jclouds.reflect.Invocation;
import org.jclouds.rest.Binder;
import org.jclouds.rest.InputParamValidator;
import org.jclouds.rest.annotations.ApiVersion;
import org.jclouds.rest.annotations.BinderParam;
import org.jclouds.rest.annotations.BuildVersion;
import org.jclouds.rest.annotations.Endpoint;
import org.jclouds.rest.annotations.EndpointParam;
import org.jclouds.rest.annotations.FormParams;
import org.jclouds.rest.annotations.Headers;
import org.jclouds.rest.annotations.MapBinder;
import org.jclouds.rest.annotations.OverrideRequestFilters;
import org.jclouds.rest.annotations.ParamParser;
import org.jclouds.rest.annotations.PartParam;
import org.jclouds.rest.annotations.PayloadParam;
import org.jclouds.rest.annotations.PayloadParams;
import org.jclouds.rest.annotations.QueryParams;
import org.jclouds.rest.annotations.RequestFilters;
import org.jclouds.rest.annotations.SkipEncoding;
import org.jclouds.rest.annotations.VirtualHost;
import org.jclouds.rest.annotations.WrapWith;
import org.jclouds.rest.binders.BindMapToStringPayload;
import org.jclouds.rest.binders.BindToJsonPayloadWrappedWith;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.base.MoreObjects;
import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.base.Supplier;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSet.Builder;
import com.google.common.collect.LinkedHashMultimap;
import com.google.common.collect.LinkedListMultimap;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.primitives.Chars;
import com.google.common.reflect.Invokable;
import com.google.common.reflect.Parameter;
import com.google.inject.Inject;
import com.google.inject.Injector;
import com.google.inject.Key;
import com.google.inject.TypeLiteral;

public class RestAnnotationProcessor implements Function<Invocation, HttpRequest> {

   @Resource
   protected Logger logger = Logger.NULL;

   private static final Function<? super Entry<String, Object>, ? extends Part> ENTRY_TO_PART = new Function<Entry<String, Object>, Part>() {
      @Override
      public Part apply(Entry<String, Object> from) {
         return Part.create(from.getKey(), from.getValue().toString());
      }
   };

   private final Injector injector;
   private final HttpUtils utils;
   private final ContentMetadataCodec contentMetadataCodec;
   private final Supplier<Credentials> credentials;
   private final String apiVersion;
   private final String buildVersion;
   private final InputParamValidator inputParamValidator;
   private final GetAcceptHeaders getAcceptHeaders;
   private final Invocation caller;
   private final boolean stripExpectHeader;
   private final boolean connectionCloseHeader;

   @Inject
   private RestAnnotationProcessor(Injector injector,
         @Provider Supplier<Credentials> credentials, @ApiVersion String apiVersion, @BuildVersion String buildVersion,
         HttpUtils utils, ContentMetadataCodec contentMetadataCodec, InputParamValidator inputParamValidator,
         GetAcceptHeaders getAcceptHeaders, @Nullable @Named("caller") Invocation caller,
         @Named(Constants.PROPERTY_STRIP_EXPECT_HEADER) boolean stripExpectHeader,
         @Named(Constants.PROPERTY_CONNECTION_CLOSE_HEADER) boolean connectionCloseHeader) {
      this.injector = injector;
      this.utils = utils;
      this.contentMetadataCodec = contentMetadataCodec;
      this.credentials = credentials;
      this.apiVersion = apiVersion;
      this.buildVersion = buildVersion;
      this.inputParamValidator = inputParamValidator;
      this.getAcceptHeaders = getAcceptHeaders;
      this.caller = caller;
      this.stripExpectHeader = stripExpectHeader;
      this.connectionCloseHeader = connectionCloseHeader;
   }

   /**
    * Note this is dangerous as it cannot pass the inheriting class! Using this
    * when subclassing interfaces may result in lost data.
    */
   @Deprecated
   public GeneratedHttpRequest createRequest(Invokable<?, ?> invokable, List<Object> args) {
      return apply(Invocation.create(invokable, args));
   }

   @Override
   public GeneratedHttpRequest apply(Invocation invocation) {
      checkNotNull(invocation, "invocation");
      inputParamValidator.validateMethodParametersOrThrow(invocation, getInvokableParameters(invocation.getInvokable()));

      Optional<URI> endpoint = Optional.absent();
      HttpRequest r = findOrNull(invocation.getArgs(), HttpRequest.class);
      if (r != null) {
         endpoint = Optional.fromNullable(r.getEndpoint());
         if (endpoint.isPresent())
            logger.trace("using endpoint %s from invocation.getArgs() for %s", endpoint, invocation);
      } else {
         // If there is no explicit HttpRequest parameter, try to find the endpoint. When using
         // delegate apis, the endpoint defined in the callee takes precedence
         endpoint = getEndpointFor(invocation);
         if (!endpoint.isPresent()) {
            if (caller != null) {
               endpoint = getEndpointFor(caller);
               if (endpoint.isPresent())
                  logger.trace("using endpoint %s from caller %s for %s", endpoint, caller, invocation);
               else
                  endpoint = findEndpoint(invocation);
            } else {
               endpoint = findEndpoint(invocation);
            }
         }
      }

      if (!endpoint.isPresent())
         throw new NoSuchElementException(format("no endpoint found for %s", invocation));
      GeneratedHttpRequest.Builder requestBuilder = GeneratedHttpRequest.builder().invocation(invocation)
            .caller(caller);
      String requestMethod = null;
      if (r != null) {
         requestMethod = r.getMethod();
         requestBuilder.fromHttpRequest(r);
      } else {
         requestMethod = tryFindHttpMethod(invocation.getInvokable()).get();
         requestBuilder.method(requestMethod);
      }

      requestBuilder.filters(getFiltersIfAnnotated(invocation));
      if (stripExpectHeader) {
         requestBuilder.filter(new StripExpectHeader());
      }
      if (connectionCloseHeader) {
         requestBuilder.filter(new ConnectionCloseHeader());
      }

      Multimap<String, Object> tokenValues = LinkedHashMultimap.create();

      tokenValues.put(Constants.PROPERTY_IDENTITY, credentials.get().identity);
      tokenValues.put(Constants.PROPERTY_API_VERSION, apiVersion);
      tokenValues.put(Constants.PROPERTY_BUILD_VERSION, buildVersion);
      // URI template in rfc6570 form
      UriBuilder uriBuilder = uriBuilder(endpoint.get().toString());

      overridePathEncoding(uriBuilder, invocation);

      boolean encodeFullPath = !isEncodedUsed(invocation);
      if (caller != null)
         tokenValues.putAll(addPathAndGetTokens(caller, uriBuilder, encodeFullPath));
      tokenValues.putAll(addPathAndGetTokens(invocation, uriBuilder, encodeFullPath));
      Multimap<String, Object> formParams;
      if (caller != null) {
         formParams = addFormParams(tokenValues, caller);
         formParams.putAll(addFormParams(tokenValues, invocation));
      } else {
         formParams = addFormParams(tokenValues, invocation);
      }

      Multimap<String, Object> queryParams = addQueryParams(tokenValues, invocation);

      Multimap<String, String> headers;
      if (caller != null) {
         headers = buildHeaders(tokenValues, caller);
         headers.putAll(buildHeaders(tokenValues, invocation));
      } else {
         headers = buildHeaders(tokenValues, invocation);
      }

      if (r != null)
         headers.putAll(r.getHeaders());

      if (shouldAddHostHeader(invocation)) {
         StringBuilder hostHeader = new StringBuilder(endpoint.get().getHost());
         if (endpoint.get().getPort() != -1)
            hostHeader.append(":").append(endpoint.get().getPort());
         headers.put(HOST, hostHeader.toString());
      }

      Payload payload = null;
      for (HttpRequestOptions options : findOptionsIn(invocation)) {
         injector.injectMembers(options);  // TODO test case
         for (Entry<String, String> header : options.buildRequestHeaders().entries()) {
            headers.put(header.getKey(), replaceTokens(header.getValue(), tokenValues));
         }
         for (Entry<String, String> query : options.buildQueryParameters().entries()) {
            queryParams.put(urlEncode(query.getKey(), '/', ','),
                  new QueryValue(replaceTokens(query.getValue(), tokenValues), false));
         }
         for (Entry<String, String> form : options.buildFormParameters().entries()) {
            formParams.put(form.getKey(), replaceTokens(form.getValue(), tokenValues));
         }

         String pathSuffix = options.buildPathSuffix();
         if (pathSuffix != null) {
            uriBuilder.appendPath(pathSuffix);
         }
         String stringPayload = options.buildStringPayload();
         if (stringPayload != null)
            payload = Payloads.newStringPayload(stringPayload);
      }

      if (!queryParams.isEmpty()) {
         uriBuilder.query(queryParams);
      }

      requestBuilder.headers(filterOutContentHeaders(headers));

      // Query parameter encoding is handled in the annotation processor
      requestBuilder.endpoint(uriBuilder.build(convertUnsafe(tokenValues), /*encodePath=*/encodeFullPath));

      if (payload == null) {
         PayloadEnclosing payloadEnclosing = findOrNull(invocation.getArgs(), PayloadEnclosing.class);
         payload = (payloadEnclosing != null) ? payloadEnclosing.getPayload() : findOrNull(invocation.getArgs(),
               Payload.class);
      }

      List<? extends Part> parts = getParts(invocation, ImmutableMultimap.<String, Object> builder()
            .putAll(tokenValues).putAll(formParams).build());

      if (!parts.isEmpty()) {
         if (!formParams.isEmpty()) {
            parts = newLinkedList(concat(transform(formParams.entries(), ENTRY_TO_PART), parts));
         }
         payload = new MultipartForm(MultipartForm.BOUNDARY, parts);
      } else if (!formParams.isEmpty()) {
         payload = Payloads.newUrlEncodedFormPayload(transformValues(formParams, NullableToStringFunction.INSTANCE));
      } else if (headers.containsKey(CONTENT_TYPE) && !HttpRequest.NON_PAYLOAD_METHODS.contains(requestMethod)) {
         if (payload == null)
            payload = Payloads.newByteArrayPayload(new byte[] {});
         payload.getContentMetadata().setContentType(get(headers.get(CONTENT_TYPE), 0));
      }
      if (payload != null) {
         requestBuilder.payload(payload);
      }
      GeneratedHttpRequest request = requestBuilder.build();

      org.jclouds.rest.MapBinder mapBinder = getMapPayloadBinderOrNull(invocation);
      if (mapBinder != null) {
         Map<String, Object> mapParams;
         if (caller != null) {
            mapParams = buildPayloadParams(caller);
            mapParams.putAll(buildPayloadParams(invocation));
         } else {
            mapParams = buildPayloadParams(invocation);
         }
         if (invocation.getInvokable().isAnnotationPresent(PayloadParams.class)) {
            PayloadParams params = invocation.getInvokable().getAnnotation(PayloadParams.class);
            addMapPayload(mapParams, params, headers, tokenValues);
         }
         request = mapBinder.bindToRequest(request, mapParams);
      } else {
         request = decorateRequest(request);
      }

      if (request.getPayload() != null) {
         contentMetadataCodec.fromHeaders(request.getPayload().getContentMetadata(), headers);
      }

      request = stripExpectHeaderIfContentZero(request);

      utils.checkRequestHasRequiredProperties(request);
      return request;
   }

   private static <T> T findOrNull(Iterable<Object> args, Class<T> clazz) {
      return clazz.cast(tryFind(args, instanceOf(clazz)).orNull());
   }

   private static <K, V> Map<K, V> convertUnsafe(Multimap<K, V> in) {
      LinkedHashMap<K, V> out = Maps.newLinkedHashMap();
      for (Entry<K, V> entry : in.entries()) {
         out.put(entry.getKey(), entry.getValue());
      }
      return ImmutableMap.copyOf(out);
   }

   private void overridePathEncoding(UriBuilder uriBuilder, Invocation invocation) {
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(SkipEncoding.class)) {
         uriBuilder.skipPathEncoding(Chars.asList(invocation.getInvokable().getOwnerType().getRawType()
               .getAnnotation(SkipEncoding.class).value()));
      }
      if (invocation.getInvokable().isAnnotationPresent(SkipEncoding.class)) {
         uriBuilder.skipPathEncoding(Chars.asList(invocation.getInvokable().getAnnotation(SkipEncoding.class).value()));
      }
   }

   // different than guava as accepts null
   private static enum NullableToStringFunction implements Function<Object, String> {
      INSTANCE;
      @Override
      public String apply(Object o) {
         if (o == null)
            return null;
         return o.toString();
      }
   }

   protected Optional<URI> findEndpoint(Invocation invocation) {
      Optional<URI> endpoint = getEndpointFor(invocation);
      if (endpoint.isPresent())
         logger.trace("using endpoint %s for %s", endpoint, invocation);
      if (!endpoint.isPresent()) {
         logger.trace("looking up default endpoint for %s", invocation);
         endpoint = Optional.fromNullable(injector.getInstance(
               Key.get(uriSupplierLiteral, org.jclouds.location.Provider.class)).get());
         if (endpoint.isPresent())
            logger.trace("using default endpoint %s for %s", endpoint, invocation);
      }
      return endpoint;
   }

   private Multimap<String, Object> addPathAndGetTokens(Invocation invocation, UriBuilder uriBuilder,
                                                        boolean encodeFullPath) {
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(Path.class))
         uriBuilder.appendPath(invocation.getInvokable().getOwnerType().getRawType().getAnnotation(Path.class).value());
      if (invocation.getInvokable().isAnnotationPresent(Path.class))
         uriBuilder.appendPath(invocation.getInvokable().getAnnotation(Path.class).value());
      return getPathParamKeyValues(invocation, encodeFullPath);
   }

   private Multimap<String, Object> addFormParams(Multimap<String, ?> tokenValues, Invocation invocation) {
      Multimap<String, Object> formMap = LinkedListMultimap.create();
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(FormParams.class)) {
         FormParams form = invocation.getInvokable().getOwnerType().getRawType().getAnnotation(FormParams.class);
         addForm(formMap, form, tokenValues);
      }

      if (invocation.getInvokable().isAnnotationPresent(FormParams.class)) {
         FormParams form = invocation.getInvokable().getAnnotation(FormParams.class);
         addForm(formMap, form, tokenValues);
      }

      for (Entry<String, Object> form : getFormParamKeyValues(invocation).entries()) {
         formMap.put(form.getKey(), replaceTokens(form.getValue().toString(), tokenValues));
      }
      return formMap;
   }

   private Multimap<String, Object> addQueryParams(Multimap<String, ?> tokenValues, Invocation invocation) {
      Multimap<String, Object> queryMap = LinkedListMultimap.create();
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(QueryParams.class)) {
         QueryParams query = invocation.getInvokable().getOwnerType().getRawType().getAnnotation(QueryParams.class);
         addQuery(queryMap, query, tokenValues);
      }

      if (invocation.getInvokable().isAnnotationPresent(QueryParams.class)) {
         QueryParams query = invocation.getInvokable().getAnnotation(QueryParams.class);
         addQuery(queryMap, query, tokenValues);
      }

      for (Entry<String, Object> query : getQueryParamKeyValues(invocation, tokenValues).entries()) {
         queryMap.put(query.getKey(), query.getValue());
      }
      return queryMap;
   }

   private void addForm(Multimap<String, Object> formParams, FormParams form, Multimap<String, ?> tokenValues) {
      for (int i = 0; i < form.keys().length; i++) {
         if (form.values()[i].equals(FormParams.NULL)) {
            formParams.removeAll(form.keys()[i]);
            formParams.put(form.keys()[i], null);
         } else {
            formParams.put(form.keys()[i], replaceTokens(form.values()[i], tokenValues));
         }
      }
   }

   private void addQuery(Multimap<String, Object> queryParams, QueryParams query, Multimap<String, ?> tokenValues) {
      for (int i = 0; i < query.keys().length; i++) {
         String key = urlEncode(query.keys()[i], '/', ',');
         if (query.values()[i].equals(QueryParams.NULL)) {
            queryParams.removeAll(key);
            queryParams.put(key, null);
         } else {
            queryParams.put(key, new QueryValue(replaceTokens(query.values()[i], tokenValues), false));
         }
      }
   }

   private void addMapPayload(Map<String, Object> postParams, PayloadParams mapDefaults,
         Multimap<String, String> headers, Multimap<String, Object> tokenValues) {
      for (int i = 0; i < mapDefaults.keys().length; i++) {
         if (mapDefaults.values()[i].equals(PayloadParams.NULL)) {
            postParams.put(mapDefaults.keys()[i], null);
         } else {
            postParams.put(mapDefaults.keys()[i], replaceTokens(replaceTokens(mapDefaults.values()[i], headers), tokenValues));
         }
      }
   }

   private List<HttpRequestFilter> getFiltersIfAnnotated(Invocation invocation) {
      List<HttpRequestFilter> filters = newArrayList();
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(RequestFilters.class)) {
         for (Class<? extends HttpRequestFilter> clazz : invocation.getInvokable().getOwnerType().getRawType()
               .getAnnotation(RequestFilters.class).value()) {
            HttpRequestFilter instance = injector.getInstance(clazz);
            filters.add(instance);
            logger.trace("adding filter %s from annotation on %s", instance, invocation.getInvokable().getOwnerType()
                  .getRawType().getName());
         }
      }
      if (invocation.getInvokable().isAnnotationPresent(RequestFilters.class)) {
         if (invocation.getInvokable().isAnnotationPresent(OverrideRequestFilters.class))
            filters.clear();
         for (Class<? extends HttpRequestFilter> clazz : invocation.getInvokable().getAnnotation(RequestFilters.class)
               .value()) {
            HttpRequestFilter instance = injector.getInstance(clazz);
            filters.add(instance);
            logger.trace("adding filter %s from annotation on %s", instance, invocation.getInvokable().getName());
         }
      }
      return filters;
   }

   @VisibleForTesting
   static URI getEndpointInParametersOrNull(Invocation invocation, Injector injector) {
      Collection<Parameter> endpointParams = parametersWithAnnotation(invocation.getInvokable(), EndpointParam.class);
      if (endpointParams.isEmpty())
         return null;
      checkState(endpointParams.size() == 1, "invocation.getInvoked() %s has too many EndpointParam annotations",
            invocation.getInvokable());
      Parameter endpointParam = get(endpointParams, 0);
      Function<Object, URI> parser = injector.getInstance(endpointParam.getAnnotation(EndpointParam.class).parser());
      int position = endpointParam.hashCode();  // guava issue 1243
      try {
         URI returnVal = parser.apply(invocation.getArgs().get(position));
         checkArgument(returnVal != null,
               "endpoint for [%s] not configured for %s", position, invocation.getInvokable());
         return returnVal;
      } catch (NullPointerException e) {
         throw new IllegalArgumentException(format("argument at index %d on invocation.getInvoked() %s was null",
               position, invocation.getInvokable()), e);
      }
   }

   private static Collection<Parameter> parametersWithAnnotation(Invokable<?, ?> invokable,
         final Class<? extends Annotation> annotationType) {
      return filter(getInvokableParameters(invokable), new Predicate<Parameter>() {
         public boolean apply(Parameter in) {
            return in.isAnnotationPresent(annotationType);
         }
      });
   }

   private static final TypeLiteral<Supplier<URI>> uriSupplierLiteral = new TypeLiteral<Supplier<URI>>() {
   };

   protected Optional<URI> getEndpointFor(Invocation invocation) {
      URI endpoint = getEndpointInParametersOrNull(invocation, injector);
      if (endpoint == null) {
         Endpoint annotation;
         if (invocation.getInvokable().isAnnotationPresent(Endpoint.class)) {
            annotation = invocation.getInvokable().getAnnotation(Endpoint.class);
         } else if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(Endpoint.class)) {
            annotation = invocation.getInvokable().getOwnerType().getRawType().getAnnotation(Endpoint.class);
         } else {
            logger.trace("no annotations on class or invocation.getInvoked(): %s", invocation.getInvokable());
            return Optional.absent();
         }
         endpoint = injector.getInstance(Key.get(uriSupplierLiteral, annotation.value())).get();
      }
      URI provider = injector.getInstance(Key.get(uriSupplierLiteral, org.jclouds.location.Provider.class)).get();
      return Optional.fromNullable(addHostIfMissing(endpoint, provider));
   }

   @VisibleForTesting
   static URI addHostIfMissing(URI original, URI withHost) {
      checkNotNull(withHost, "URI withHost cannot be null");
      checkArgument(withHost.getHost() != null, "URI withHost must have host:" + withHost);
      if (original == null)
         return null;
      if (original.getHost() != null)
         return original;
      String host = withHost.toString();
      URI baseURI = host.endsWith("/") ? withHost : URI.create(host + "/");
      return baseURI.resolve(original);
   }

   private org.jclouds.rest.MapBinder getMapPayloadBinderOrNull(Invocation invocation) {
      if (invocation.getArgs() != null) {
         for (Object arg : invocation.getArgs()) {
            if (arg instanceof Object[]) {
               Object[] postBinders = (Object[]) arg;
               if (postBinders.length == 0) {
               } else if (postBinders.length == 1) {
                  if (postBinders[0] instanceof org.jclouds.rest.MapBinder) {
                     org.jclouds.rest.MapBinder binder = (org.jclouds.rest.MapBinder) postBinders[0];
                     injector.injectMembers(binder);
                     return binder;
                  }
               } else {
                  if (postBinders[0] instanceof org.jclouds.rest.MapBinder) {
                     throw new IllegalArgumentException(
                           "we currently do not support multiple varinvocation.getArgs() postBinders in: "
                                 + invocation.getInvokable().getName());
                  }
               }
            } else if (arg instanceof org.jclouds.rest.MapBinder) {
               org.jclouds.rest.MapBinder binder = (org.jclouds.rest.MapBinder) arg;
               injector.injectMembers(binder);
               return binder;
            }
         }
      }
      if (invocation.getInvokable().isAnnotationPresent(MapBinder.class)) {
         return injector.getInstance(invocation.getInvokable().getAnnotation(MapBinder.class).value());
      } else if (invocation.getInvokable().isAnnotationPresent(org.jclouds.rest.annotations.Payload.class)) {
         return injector.getInstance(BindMapToStringPayload.class);
      } else if (invocation.getInvokable().isAnnotationPresent(WrapWith.class)) {
         return injector.getInstance(BindToJsonPayloadWrappedWith.Factory.class).create(
               invocation.getInvokable().getAnnotation(WrapWith.class).value());
      }
      return null;
   }

   private boolean shouldAddHostHeader(Invocation invocation) {
      return invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(VirtualHost.class) || invocation
            .getInvokable().isAnnotationPresent(VirtualHost.class);
   }

   private GeneratedHttpRequest decorateRequest(GeneratedHttpRequest request) throws NegativeArraySizeException {
      Invocation invocation = request.getInvocation();
      List<Object> args = request.getInvocation().getArgs();
      Set<Parameter> binderOrWrapWith = ImmutableSet.copyOf(concat(
            parametersWithAnnotation(invocation.getInvokable(), BinderParam.class),
            parametersWithAnnotation(invocation.getInvokable(), WrapWith.class)));
      OUTER: for (Parameter entry : binderOrWrapWith) {
         int position = entry.hashCode();
         boolean shouldBreak = false;
         Binder binder;
         if (entry.isAnnotationPresent(BinderParam.class))
            binder = injector.getInstance(entry.getAnnotation(BinderParam.class).value());
         else
            binder = injector.getInstance(BindToJsonPayloadWrappedWith.Factory.class).create(
                  entry.getAnnotation(WrapWith.class).value());
         Object arg = args.size() >= position + 1 ? args.get(position) : null;
         if (args.size() >= position + 1 && arg != null) {
            Class<?> parameterType = entry.getType().getRawType();
            Class<? extends Object> argType = arg.getClass();
            if (!argType.isArray() && parameterType.isArray()) {// TODO: &&
                                                                // invocation.getInvokable().isVarArgs())
                                                                // {
               int arrayLength = args.size() - getInvokableParameters(invocation.getInvokable()).size() + 1;
               if (arrayLength == 0)
                  break OUTER;
               arg = (Object[]) Array.newInstance(arg.getClass(), arrayLength);
               System.arraycopy(args.toArray(), position, arg, 0, arrayLength);
               shouldBreak = true;
            } else if (argType.isArray() && parameterType.isArray()) {// TODO:
                                                                      // &&
                                                                      // invocation.getInvokable().isVarArgs())
                                                                      // {
            } else {
               if (arg.getClass().isArray()) {
                  Object[] payloadArray = (Object[]) arg;
                  arg = payloadArray.length > 0 ? payloadArray[0] : null;
               }
            }
            if (arg != null) {
               request = binder.bindToRequest(request, arg);
            }
            if (shouldBreak)
               break OUTER;
         } else {
            if (position + 1 == getInvokableParameters(invocation.getInvokable()).size() && entry.getType().isArray())// TODO:
                                                                                                              // &&
                                                                                                              // invocation.getInvokable().isVarArgs())
               continue OUTER;

            if (entry.isAnnotationPresent(Nullable.class)) {
               continue OUTER;
            }
            checkNotNull(arg, invocation.getInvokable().getName() + " parameter " + (position + 1));
         }
      }
      return request;
   }

   private static final LoadingCache<Invokable<?, ?>, Set<Integer>> invokableToIndexesOfOptions = CacheBuilder
         .newBuilder().build(new CacheLoader<Invokable<?, ?>, Set<Integer>>() {
            @Override
            public Set<Integer> load(Invokable<?, ?> invokable) {
               Builder<Integer> toReturn = ImmutableSet.builder();
               for (Parameter param : getInvokableParameters(invokable)) {
                  Class<?> type = param.getType().getRawType();
                  if (HttpRequestOptions.class.isAssignableFrom(type)
                        || HttpRequestOptions[].class.isAssignableFrom(type))
                     toReturn.add(param.hashCode());
               }
               return toReturn.build();
            }
         });

   private Set<HttpRequestOptions> findOptionsIn(Invocation invocation) {
      ImmutableSet.Builder<HttpRequestOptions> result = ImmutableSet.builder();
      for (int index : invokableToIndexesOfOptions.getUnchecked(invocation.getInvokable())) {
         if (invocation.getArgs().size() >= index + 1) {// accommodate
                                                        // varinvocation.getArgs()
            if (invocation.getArgs().get(index) instanceof Object[]) {
               for (Object option : (Object[]) invocation.getArgs().get(index)) {
                  if (option instanceof HttpRequestOptions) {
                     result.add((HttpRequestOptions) option);
                  }
               }
            } else {
               for (; index < invocation.getArgs().size(); index++) {
                  if (invocation.getArgs().get(index) instanceof HttpRequestOptions) {
                     result.add((HttpRequestOptions) invocation.getArgs().get(index));
                  }
               }
            }
         }
      }
      return result.build();
   }

   private Multimap<String, String> buildHeaders(Multimap<String, ?> tokenValues, Invocation invocation) {
      Multimap<String, String> headers = LinkedHashMultimap.create();
      addHeaderIfAnnotationPresentOnMethod(headers, invocation, tokenValues);
      for (Parameter headerParam : parametersWithAnnotation(invocation.getInvokable(), HeaderParam.class)) {
         Annotation key = headerParam.getAnnotation(HeaderParam.class);
         String value = invocation.getArgs().get(headerParam.hashCode()).toString();
         value = replaceTokens(value, tokenValues);
         headers.put(((HeaderParam) key).value(), value);
      }
      addProducesIfPresentOnTypeOrMethod(headers, invocation);
      addConsumesIfPresentOnTypeOrMethod(headers, invocation);
      return headers;
   }

   private void addConsumesIfPresentOnTypeOrMethod(Multimap<String, String> headers, Invocation invocation) {
      Set<String> accept = getAcceptHeaders.apply(invocation);
      if (!accept.isEmpty())
         headers.replaceValues(ACCEPT, accept);
   }

   private void addProducesIfPresentOnTypeOrMethod(Multimap<String, String> headers, Invocation invocation) {
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(Produces.class)) {
         Produces header = invocation.getInvokable().getOwnerType().getRawType().getAnnotation(Produces.class);
         headers.replaceValues(CONTENT_TYPE, asList(header.value()));
      }
      if (invocation.getInvokable().isAnnotationPresent(Produces.class)) {
         Produces header = invocation.getInvokable().getAnnotation(Produces.class);
         headers.replaceValues(CONTENT_TYPE, asList(header.value()));
      }
   }

   private void addHeaderIfAnnotationPresentOnMethod(Multimap<String, String> headers, Invocation invocation,
         Multimap<String, ?> tokenValues) {
      if (invocation.getInvokable().getOwnerType().getRawType().isAnnotationPresent(Headers.class)) {
         Headers header = invocation.getInvokable().getOwnerType().getRawType().getAnnotation(Headers.class);
         addHeader(headers, header, tokenValues);
      }
      if (invocation.getInvokable().isAnnotationPresent(Headers.class)) {
         Headers header = invocation.getInvokable().getAnnotation(Headers.class);
         addHeader(headers, header, tokenValues);
      }
   }

   private static void addHeader(Multimap<String, String> headers, Headers header, Multimap<String, ?> tokenValues) {
      for (int i = 0; i < header.keys().length; i++) {
         String value = header.values()[i];
         value = replaceTokens(value, tokenValues);
         // urlEncode may have less entries than keys e.g. default value of {}
         if (i < header.urlEncode().length && header.urlEncode()[i])
            value = urlEncode(value, '/');
         headers.put(header.keys()[i], value);
      }
   }

   private static List<Part> getParts(Invocation invocation, Multimap<String, ?> tokenValues) {
      ImmutableList.Builder<Part> parts = ImmutableList.<Part> builder();
      for (Parameter param : parametersWithAnnotation(invocation.getInvokable(), PartParam.class)) {
         PartParam partParam = param.getAnnotation(PartParam.class);
         PartOptions options = new PartOptions();
         if (!PartParam.NO_CONTENT_TYPE.equals(partParam.contentType()))
            options.contentType(partParam.contentType());
         if (!PartParam.NO_FILENAME.equals(partParam.filename()))
            options.filename(replaceTokens(partParam.filename(), tokenValues));
         Object arg = invocation.getArgs().get(param.hashCode());
         checkNotNull(arg, partParam.name());
         Part part = Part.create(partParam.name(), newPayload(arg), options);
         parts.add(part);
      }
      return parts.build();
   }

   private static GeneratedHttpRequest stripExpectHeaderIfContentZero(GeneratedHttpRequest request) {
      boolean isBodyEmpty = true;
      if (request.getPayload() != null) {
         Long length = request.getPayload().getContentMetadata().getContentLength();
         isBodyEmpty = length != null && length == 0;
      }
      if (isBodyEmpty) {
         request = request.toBuilder().removeHeader("Expect").build();
      }
      return request;
   }

   private boolean isEncodedUsed(Invocation invocation) {
      return !parametersWithAnnotation(invocation.getInvokable(), Encoded.class).isEmpty();
   }

   private Multimap<String, Object> getPathParamKeyValues(Invocation invocation, boolean encodeFullPath) {
      Multimap<String, Object> pathParamValues = LinkedHashMultimap.create();
      for (Parameter param : parametersWithAnnotation(invocation.getInvokable(), PathParam.class)) {
         PathParam pathParam = param.getAnnotation(PathParam.class);
         String paramKey = pathParam.value();
         Optional<?> paramValue = getParamValue(invocation, param.getAnnotation(ParamParser.class), param.hashCode(),
               paramKey);
         if (paramValue.isPresent()) {
            if (!encodeFullPath && !param.isAnnotationPresent(Encoded.class)) {
               pathParamValues.put(paramKey, urlEncode(paramValue.get().toString()));
            } else {
               pathParamValues.put(paramKey, paramValue.get().toString());
            }
         }
      }
      return pathParamValues;
   }

   private Optional<?> getParamValue(Invocation invocation, @Nullable ParamParser extractor, int argIndex,
         String paramKey) {
      Object arg = invocation.getArgs().get(argIndex);
      if (extractor != null && checkPresentOrNullable(invocation, paramKey, argIndex, arg)) {
         // ParamParsers can deal with nullable parameters
         arg = injector.getInstance(extractor.value()).apply(arg);
      }
      checkPresentOrNullable(invocation, paramKey, argIndex, arg);
      return Optional.fromNullable(arg);
   }

   private boolean checkPresentOrNullable(Invocation invocation, String paramKey, int argIndex, Object arg) {
      if (arg == null && !getInvokableParameters(invocation.getInvokable()).get(argIndex).isAnnotationPresent(Nullable.class))
         throw new NullPointerException(format("param{%s} for invocation %s.%s", paramKey, invocation.getInvokable()
               .getOwnerType().getRawType().getSimpleName(), invocation.getInvokable().getName()));
      return true;
   }

   private Multimap<String, Object> getFormParamKeyValues(Invocation invocation) {
      Multimap<String, Object> formParamValues = LinkedHashMultimap.create();
      for (Parameter param : parametersWithAnnotation(invocation.getInvokable(), FormParam.class)) {
         FormParam formParam = param.getAnnotation(FormParam.class);
         String paramKey = formParam.value();
         Optional<?> paramValue = getParamValue(invocation, param.getAnnotation(ParamParser.class), param.hashCode(),
               paramKey);
         if (paramValue.isPresent())
            formParamValues.put(paramKey, paramValue.get().toString());
      }
      return formParamValues;
   }

   private Multimap<String, Object> getQueryParamKeyValues(Invocation invocation, Multimap<String, ?> tokenValues) {
      Multimap<String, Object> queryParamValues = LinkedHashMultimap.create();
      for (Parameter param : parametersWithAnnotation(invocation.getInvokable(), QueryParam.class)) {
         QueryParam queryParam = param.getAnnotation(QueryParam.class);
         String paramKey = urlEncode(queryParam.value(), '/', ',');
         Optional<?> paramValue = getParamValue(invocation, param.getAnnotation(ParamParser.class), param.hashCode(),
               paramKey);
         boolean encoded = param.isAnnotationPresent(Encoded.class);
         if (paramValue.isPresent())
            if (paramValue.get() instanceof Iterable) {
               @SuppressWarnings("unchecked")
               Iterable<String> iterableStrings = transform(Iterable.class.cast(paramValue.get()), toStringFunction());
               List<QueryValue> values = new ArrayList<QueryValue>();
               for (String stringValue : iterableStrings) {
                  values.add(new QueryValue(replaceTokens(stringValue, tokenValues), encoded));
               }
               queryParamValues.putAll(paramKey, values);
            } else {
               String value = paramValue.get().toString();
               queryParamValues.put(paramKey, new QueryValue(replaceTokens(value, tokenValues), encoded));
            }
      }
      return queryParamValues;
   }

   private Map<String, Object> buildPayloadParams(Invocation invocation) {
      Map<String, Object> payloadParamValues = Maps.newLinkedHashMap();
      for (Parameter param : parametersWithAnnotation(invocation.getInvokable(), PayloadParam.class)) {
         PayloadParam payloadParam = param.getAnnotation(PayloadParam.class);
         String paramKey = payloadParam.value();
         Optional<?> paramValue = getParamValue(invocation, param.getAnnotation(ParamParser.class), param.hashCode(),
               paramKey);
         if (paramValue.isPresent())
            payloadParamValues.put(paramKey, paramValue.get());
      }
      return payloadParamValues;
   }

   @Override
   public String toString() {
      String callerString = caller != null ? String.format("%s.%s%s", caller.getInvokable().getOwnerType().getRawType().getSimpleName(),
            caller.getInvokable().getName(), caller.getArgs()) : null;
      return MoreObjects.toStringHelper("").omitNullValues().add("caller", callerString).toString();
   }
}
