package org.jboss.resteasy.client.jaxrs.internal;

import org.jboss.resteasy.client.exception.WebApplicationExceptionWrapper;
import org.jboss.resteasy.client.jaxrs.AsyncClientHttpEngine;
import org.jboss.resteasy.client.jaxrs.AsyncClientHttpEngine.ResultExtractor;
import org.jboss.resteasy.client.jaxrs.ClientHttpEngine;
import org.jboss.resteasy.client.jaxrs.ResteasyClient;
import org.jboss.resteasy.client.jaxrs.internal.proxy.ClientInvoker;
import org.jboss.resteasy.core.interception.AbstractWriterInterceptorContext;
import org.jboss.resteasy.core.interception.ClientWriterInterceptorContext;
import org.jboss.resteasy.plugins.providers.sse.EventInput;
import org.jboss.resteasy.specimpl.MultivaluedTreeMap;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.jboss.resteasy.spi.ResteasyProviderFactory.CloseableContext;
import org.jboss.resteasy.util.DelegatingOutputStream;
import org.jboss.resteasy.util.Types;
import org.reactivestreams.Publisher;

import javax.ws.rs.BadRequestException;
import javax.ws.rs.ClientErrorException;
import javax.ws.rs.ForbiddenException;
import javax.ws.rs.InternalServerErrorException;
import javax.ws.rs.NotAcceptableException;
import javax.ws.rs.NotAllowedException;
import javax.ws.rs.NotAuthorizedException;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.NotSupportedException;
import javax.ws.rs.ProcessingException;
import javax.ws.rs.RedirectionException;
import javax.ws.rs.ServerErrorException;
import javax.ws.rs.ServiceUnavailableException;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.client.ClientRequestFilter;
import javax.ws.rs.client.ClientResponseFilter;
import javax.ws.rs.client.Entity;
import javax.ws.rs.client.Invocation;
import javax.ws.rs.client.InvocationCallback;
import javax.ws.rs.client.ResponseProcessingException;
import javax.ws.rs.client.WebTarget;
import javax.ws.rs.core.Configuration;
import javax.ws.rs.core.GenericEntity;
import javax.ws.rs.core.GenericType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Variant;
import javax.ws.rs.ext.Providers;
import javax.ws.rs.ext.WriterInterceptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.function.Function;

/**
 * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
 * @version $Revision: 1 $
 */
public class ClientInvocation implements Invocation
{
   protected ResteasyClient client;

   protected ClientRequestHeaders headers;

   protected String method;

   protected Object entity;

   protected Type entityGenericType;

   protected Class<?> entityClass;

   protected Annotation[] entityAnnotations;

   protected ClientConfiguration configuration;

   protected URI uri;

   protected boolean chunked;

   protected ClientInvoker clientInvoker;

   protected WebTarget actualTarget;

   // todo need a better solution for this.  Apache Http Client 4 does not let you obtain the OutputStream before executing this request.
   // That is problematic for wrapping the output stream in e.g. a RequestFilter for transparent compressing.
   protected DelegatingOutputStream delegatingOutputStream = new DelegatingOutputStream();

   protected OutputStream entityStream = delegatingOutputStream;

   public ClientInvocation(final ResteasyClient client, final URI uri, final ClientRequestHeaders headers, final ClientConfiguration parent)
   {
      this.uri = uri;
      this.client = client;
      this.configuration = new ClientConfiguration(parent);
      this.headers = headers;
   }

   protected ClientInvocation(final ClientInvocation clientInvocation)
   {
      this.client = clientInvocation.client;
      this.configuration = new ClientConfiguration(clientInvocation.configuration);
      this.headers = new ClientRequestHeaders(this.configuration);
      MultivaluedTreeMap.copy(clientInvocation.headers.getHeaders(), this.headers.headers);
      this.method = clientInvocation.method;
      this.entity = clientInvocation.entity;
      this.entityGenericType = clientInvocation.entityGenericType;
      this.entityClass = clientInvocation.entityClass;
      this.entityAnnotations = clientInvocation.entityAnnotations;
      this.uri = clientInvocation.uri;
      this.chunked = clientInvocation.chunked;
      this.clientInvoker = clientInvocation.clientInvoker;
   }

   /**
    * Extracts result from response throwing an appropriate exception if not a successful response.
    *
    * @param responseType generic type
    * @param response response entity
    * @param annotations array of annotations
    * @param <T> type
    * @return extracted result of type T
    */
   @SuppressWarnings("unchecked")
   public static <T> T extractResult(GenericType<T> responseType, Response response, Annotation[] annotations)
   {
      int status = response.getStatus();
      if (status >= 200 && status < 300)
      {
         try
         {
            if (response.getMediaType() == null)
            {
               return null;
            }
            else
            {
               T rtn = response.readEntity(responseType, annotations);
               if (InputStream.class.isInstance(rtn) || Reader.class.isInstance(rtn)
                     || EventInput.class.isInstance(rtn) || Publisher.class.isInstance(rtn))
               {
                  if (response instanceof ClientResponse)
                  {
                     ClientResponse clientResponse = (ClientResponse) response;
                     clientResponse.noReleaseConnection();
                  }
               }
               return rtn;

            }
         }
         catch (WebApplicationException wae)
         {
            try
            {
               response.close();
            }
            catch (Exception e)
            {

            }
            throw wae;
         }
         catch (Throwable throwable)
         {
            try
            {
               response.close();
            }
            catch (Exception e)
            {

            }
            throw new ResponseProcessingException(response, throwable);
         }
         finally
         {
            if (response.getMediaType() == null)
               response.close();
         }
      }
      try
      {
         // Buffer the entity for any exception thrown as the response may have any entity the user wants
         // We don't want to leave the connection open though.
         String s = String.class.cast(response.getHeaders().getFirst("resteasy.buffer.exception.entity"));
         if (s == null || Boolean.parseBoolean(s))
         {
            response.bufferEntity();
         }
         else
         {
            // close connection
            if (response instanceof ClientResponse)
            {
               try
               {
                  ClientResponse.class.cast(response).releaseConnection();
               }
               catch (IOException e)
               {
                  // Ignore
               }
            }
         }
         if (status >= 300 && status < 400)
         {
            throw WebApplicationExceptionWrapper.wrap(new RedirectionException(response));
         }

         return handleErrorStatus(response);
      }
      finally
      {
         // close if no content
         if (response.getMediaType() == null)
            response.close();
      }

   }

   /**
    * Throw an exception.  Expecting a status of 400 or greater.
    *
    * @param response response entity
    * @param <T> type
    * @return unreachable
    */
   public static <T> T handleErrorStatus(Response response)
   {
      final int status = response.getStatus();
      switch (status)
      {
         case 400 :
            throw WebApplicationExceptionWrapper.wrap(new BadRequestException(response));
         case 401 :
            throw WebApplicationExceptionWrapper.wrap(new NotAuthorizedException(response));
         case 403 :
            throw WebApplicationExceptionWrapper.wrap(new ForbiddenException(response));
         case 404 :
            throw WebApplicationExceptionWrapper.wrap(new NotFoundException(response));
         case 405 :
            throw WebApplicationExceptionWrapper.wrap(new NotAllowedException(response));
         case 406 :
            throw WebApplicationExceptionWrapper.wrap(new NotAcceptableException(response));
         case 415 :
            throw WebApplicationExceptionWrapper.wrap(new NotSupportedException(response));
         case 500 :
            throw WebApplicationExceptionWrapper.wrap(new InternalServerErrorException(response));
         case 503 :
            throw WebApplicationExceptionWrapper.wrap(new ServiceUnavailableException(response));
         default :
            break;
      }

      if (status >= 400 && status < 500)
         throw WebApplicationExceptionWrapper.wrap(new ClientErrorException(response));
      if (status >= 500)
         throw WebApplicationExceptionWrapper.wrap(new ServerErrorException(response));

      throw WebApplicationExceptionWrapper.wrap(new WebApplicationException(response));
   }

   public ClientConfiguration getClientConfiguration()
   {
      return configuration;
   }

   public ResteasyClient getClient()
   {
      return client;
   }

   public DelegatingOutputStream getDelegatingOutputStream()
   {
      return delegatingOutputStream;
   }

   public void setDelegatingOutputStream(DelegatingOutputStream delegatingOutputStream)
   {
      this.delegatingOutputStream = delegatingOutputStream;
   }

   public OutputStream getEntityStream()
   {
      return entityStream;
   }

   public void setEntityStream(OutputStream entityStream)
   {
      this.entityStream = entityStream;
   }

   public URI getUri()
   {
      return uri;
   }

   public void setUri(URI uri)
   {
      this.uri = uri;
   }

   public Annotation[] getEntityAnnotations()
   {
      return entityAnnotations;
   }

   public void setEntityAnnotations(Annotation[] entityAnnotations)
   {
      this.entityAnnotations = entityAnnotations;
   }

   public String getMethod()
   {
      return method;
   }

   public void setMethod(String method)
   {
      this.method = method;
   }

   public void setHeaders(ClientRequestHeaders headers)
   {
      this.headers = headers;
   }

   public Map<String, Object> getMutableProperties()
   {
      return configuration.getMutableProperties();
   }

   public Object getEntity()
   {
      return entity;
   }

   public Type getEntityGenericType()
   {
      return entityGenericType;
   }

   public Class<?> getEntityClass()
   {
      return entityClass;
   }

   public ClientRequestHeaders getHeaders()
   {
      return headers;
   }

   public void setEntity(Entity<?> entity)
   {
      if (entity == null)
      {
         this.entity = null;
         this.entityAnnotations = null;
         this.entityClass = null;
         this.entityGenericType = null;
      }
      else
      {
         Object ent = entity.getEntity();
         setEntityObject(ent);
         this.entityAnnotations = entity.getAnnotations();
         Variant v = entity.getVariant();
         headers.setMediaType(v.getMediaType());
         headers.setLanguage(v.getLanguage());
         headers.header("Content-Encoding", null);
         headers.header("Content-Encoding", v.getEncoding());
      }

   }

   public void setEntityObject(Object ent)
   {
      if (ent instanceof GenericEntity)
      {
         GenericEntity<?> genericEntity = (GenericEntity<?>) ent;
         entityClass = genericEntity.getRawType();
         entityGenericType = genericEntity.getType();
         this.entity = genericEntity.getEntity();
      }
      else
      {
         if (ent == null)
         {
            this.entity = null;
            this.entityClass = null;
            this.entityGenericType = null;
         }
         else
         {
            this.entity = ent;
            this.entityClass = ent.getClass();
            this.entityGenericType = ent.getClass();
         }
      }
   }

   public void writeRequestBody(OutputStream outputStream) throws IOException
   {
      if (entity == null)
      {
         return;
      }

      WriterInterceptor[] interceptors = getWriterInterceptors();
      AbstractWriterInterceptorContext ctx = new ClientWriterInterceptorContext(interceptors,
            configuration.getProviderFactory(), entity, entityClass, entityGenericType, entityAnnotations,
            headers.getMediaType(), headers.getHeaders(), outputStream, getMutableProperties());
      ctx.proceed();
   }

   public WriterInterceptor[] getWriterInterceptors()
   {
      return configuration.getWriterInterceptors(null, null);
   }

   public ClientRequestFilter[] getRequestFilters()
   {
      return configuration.getRequestFilters(null, null);
   }

   public ClientResponseFilter[] getResponseFilters()
   {
      return configuration.getResponseFilters(null, null);
   }

   // Invocation methods

   public Configuration getConfiguration()
   {
      return configuration;
   }

   public boolean isChunked()
   {
      return chunked;
   }

   public void setChunked(boolean chunked)
   {
      this.chunked = chunked;
   }

   @Override
   public ClientResponse invoke()
   {
      try(CloseableContext ctx = pushProvidersContext())
      {
         ClientRequestContextImpl requestContext = new ClientRequestContextImpl(this);
         ClientResponse aborted = filterRequest(requestContext);

         // spec requires that aborted response go through filter/interceptor chains.
         ClientResponse response = (aborted != null) ? aborted : client.httpEngine().invoke(this);
         return filterResponse(requestContext, response);
      }
      catch (ResponseProcessingException e)
      {
         if (e.getResponse() != null)
         {
            e.getResponse().close();
         }
         throw e;
      }
   }

   @SuppressWarnings("unchecked")
   @Override
   public <T> T invoke(Class<T> responseType)
   {
      Response response = invoke();
      if (Response.class.equals(responseType))
         return (T) response;
      return extractResult(new GenericType<T>(responseType), response, null);
   }

   @SuppressWarnings("unchecked")
   @Override
   public <T> T invoke(GenericType<T> responseType)
   {
      Response response = invoke();
      if (responseType.getRawType().equals(Response.class))
         return (T) response;
      return extractResult(responseType, response, null);
   }

   @Override
   public Future<Response> submit()
   {
      return doSubmit(false, null, result -> result);
   }

   @Override
   public <T> Future<T> submit(final Class<T> responseType)
   {
      return doSubmit(false, null, getResponseTypeExtractor(responseType));
   }

   @Override
   public <T> Future<T> submit(final GenericType<T> responseType)
   {
      return doSubmit(false, null, getGenericTypeExtractor(responseType));
   }

   @SuppressWarnings({"rawtypes", "unchecked"})
   @Override
   public <T> Future<T> submit(final InvocationCallback<T> callback)
   {
      GenericType<T> genericType = (GenericType<T>) new GenericType<Object>()
      {
      };
      Type[] typeInfo = Types.getActualTypeArgumentsOfAnInterface(callback.getClass(), InvocationCallback.class);
      if (typeInfo != null)
      {
         genericType = new GenericType(typeInfo[0]);
      }

      return doSubmit(true, callback, getGenericTypeExtractor(genericType));
   }

   private <T> Future<T> doSubmit(boolean buffered, InvocationCallback<T> callback, ResultExtractor<T> extractor) {
      if (client.httpEngine() instanceof AsyncClientHttpEngine)
      {
         return asyncSubmit(getFutureExtractorFunction(buffered, callback), extractor,
                 getAsyncAbortedFunction(callback), getAsyncExceptionFunction(callback));
      }
      else
      {
         return executorSubmit(asyncInvocationExecutor(), callback, extractor);
      }
   }

   public ExecutorService asyncInvocationExecutor() {
      return client.asyncInvocationExecutor();
   }

   private <T> Function<ResultExtractor<T>, CompletableFuture<T>> getCompletableFutureExtractorFunction(boolean buffered) {
      final ClientHttpEngine httpEngine = client.httpEngine();
      return (httpEngine instanceof AsyncClientHttpEngine)
              ? ext -> ((AsyncClientHttpEngine) httpEngine).submit(this, buffered, ext, asyncInvocationExecutor()) : null;
   }

   private <T> Function<ResultExtractor<T>, Future<T>> getFutureExtractorFunction(boolean buffered, InvocationCallback<T> callback) {
      final ClientHttpEngine httpEngine = client.httpEngine();
      return (httpEngine instanceof AsyncClientHttpEngine)
              ? ext -> ((AsyncClientHttpEngine) httpEngine).submit(this, buffered, callback, ext) : null;
   }

   private static <T> Function<T, Future<T>> getAsyncAbortedFunction(InvocationCallback<T> callback) {
      return result -> {
         callCompletedNoThrow(callback, result);
         return CompletableFuture.completedFuture(result);
      };
   }

   private static <T> Function<Exception, Future<T>> getAsyncExceptionFunction(InvocationCallback<T> callback) {
      return ex -> {
         callFailedNoThrow(callback, ex);
         CompletableFuture<T> completableFuture = new CompletableFuture<>();
         completableFuture.completeExceptionally(new ExecutionException(ex));
         return completableFuture;
      };
   }

   @SuppressWarnings("unchecked")
   private static <T> ResultExtractor<T> getGenericTypeExtractor(GenericType<T> responseType) {
      return response -> {
         if (responseType.getRawType().equals(Response.class))
            return (T) response;
         return ClientInvocation.extractResult(responseType, response, null);
      };
   }

   @SuppressWarnings("unchecked")
   private static <T> ResultExtractor<T> getResponseTypeExtractor(Class<T> responseType) {
      return response -> {
         if (Response.class.equals(responseType))
            return (T) response;
         return ClientInvocation.extractResult(new GenericType<T>(responseType), response, null);
      };
   }

   public CompletableFuture<Response> submitCF()
   {
      return doSubmit(response -> response, false);
   }

   public <T> CompletableFuture<T> submitCF(final Class<T> responseType)
   {
      return doSubmit(getResponseTypeExtractor(responseType), true);
   }

   public <T> CompletableFuture<T> submitCF(final GenericType<T> responseType)
   {
      return doSubmit(getGenericTypeExtractor(responseType), true);
   }

   private <T> CompletableFuture<T> doSubmit(ResultExtractor<T> extractor, boolean buffered) {
      if (client.httpEngine() instanceof AsyncClientHttpEngine)
      {
         return asyncSubmit(getCompletableFutureExtractorFunction(buffered),
                 extractor,
                 result -> CompletableFuture.completedFuture(result),
                 ex -> {
                    CompletableFuture<T> completableFuture = new CompletableFuture<>();
                    completableFuture.completeExceptionally(new ExecutionException(ex));
                    return completableFuture;
                 });
      }
      else
      {
         return executorSubmit(asyncInvocationExecutor(), null, extractor);
      }
   }

   @Override
   public Invocation property(String name, Object value)
   {
      configuration.property(name, value);
      return this;
   }

   public ClientInvoker getClientInvoker() {
      return clientInvoker;
   }

   public void setClientInvoker(ClientInvoker clientInvoker) {
      this.clientInvoker = clientInvoker;
   }

   // internals

   private CloseableContext pushProvidersContext()
   {
      CloseableContext ret = ResteasyProviderFactory.addCloseableContextDataLevel();
      ResteasyProviderFactory.pushContext(Providers.class, configuration);
      return ret;
   }

   protected ClientResponse filterRequest(ClientRequestContextImpl requestContext)
   {
      ClientRequestFilter[] requestFilters = getRequestFilters();
      ClientResponse aborted = null;
      if (requestFilters != null && requestFilters.length > 0)
      {
         for (ClientRequestFilter filter : requestFilters)
         {
            try
            {
               filter.filter(requestContext);
               if (requestContext.getAbortedWithResponse() != null)
               {
                  aborted = new AbortedResponse(configuration, requestContext.getAbortedWithResponse());
                  break;
               }
            }
            catch (ProcessingException e)
            {
               throw e;
            }
            catch (Throwable e)
            {
               throw new ProcessingException(e);
            }
         }
      }
      return aborted;
   }

   protected ClientResponse filterResponse(ClientRequestContextImpl requestContext, ClientResponse response)
   {
      response.setProperties(configuration.getMutableProperties());

      ClientResponseFilter[] responseFilters = getResponseFilters();
      if (responseFilters != null && responseFilters.length > 0)
      {
         ClientResponseContextImpl responseContext = new ClientResponseContextImpl(response);
         for (ClientResponseFilter filter : responseFilters)
         {
            try
            {
               filter.filter(requestContext, responseContext);
            }
            catch (ResponseProcessingException e)
            {
               throw e;
            }
            catch (Throwable e)
            {
               throw new ResponseProcessingException(response, e);
            }
         }
      }

      return response;
   }

   private <Q extends Future<T>, T> Q asyncSubmit(
           final Function<ResultExtractor<T>, Q> asyncHttpEngineSubmitFn,
           final ResultExtractor<T> extractor,
           final Function<T, Q> abortedFn,
           final Function<Exception, Q> exceptionFn)
   {
      final ClientRequestContextImpl requestContext = new ClientRequestContextImpl(this);
      try(CloseableContext ctx = pushProvidersContext())
      {
         ClientResponse aborted = filterRequest(requestContext);
         if (aborted != null)
         {
            // spec requires that aborted response go through filter/interceptor chains.
            aborted = filterResponse(requestContext, aborted);
            T result = extractor.extractResult(aborted);
            return abortedFn.apply(result);
         }
      }
      catch (Exception ex)
      {
         exceptionFn.apply(ex);
      }

      return asyncHttpEngineSubmitFn.apply(response -> {
         try(CloseableContext ctx = pushProvidersContext())
         {
            return extractor.extractResult(filterResponse(requestContext, response));
         }
      });
   }


   private <T> CompletableFuture<T> executorSubmit(ExecutorService executor, final InvocationCallback<T> callback,
                                                   final ResultExtractor<T> extractor)
   {
      return CompletableFuture.supplyAsync(() -> {
         // FIXME: why does this have no context?
         // ensure the future and the callback see the same result
         ClientResponse response = null;
         try
         {
            response = invoke(); // does filtering too
            T result = extractor.extractResult(response);
            callCompletedNoThrow(callback, result);
            return result;
         }
         catch (Exception e)
         {
            callFailedNoThrow(callback, e);
            throw e;
         }
         finally
         {
            if (response != null && callback != null)
               response.close();
         }
      }, executor);
   }

   private static <T> void callCompletedNoThrow(InvocationCallback<T> callback, T result)
   {
      if (callback != null)
      {
         try
         {
            callback.completed(result);
         }
         catch (Exception e)
         {
            //logger.error("ignoring exception in InvocationCallback", e);
         }
      }
   }

   private static <T> void callFailedNoThrow(InvocationCallback<T> callback, Exception exception)
   {
      if (callback != null)
      {
         try
         {
            callback.failed(exception);
         }
         catch (Exception e)
         {
            //logger.error("ignoring exception in InvocationCallback", e);
         }
      }
   }

   public void setActualTarget(WebTarget target)
   {
      this.actualTarget = target;
   }

   public WebTarget getActualTarget()
   {
      return this.actualTarget;
   }
}
