package org.jboss.resteasy.plugins.providers.jackson;

import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;

import jakarta.ws.rs.ConstrainedTo;
import jakarta.ws.rs.RuntimeType;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.ContextResolver;
import jakarta.ws.rs.ext.Provider;
import jakarta.ws.rs.ext.Providers;
import jakarta.ws.rs.ext.WriterInterceptorContext;

import org.jboss.resteasy.core.MediaTypeMap;
import org.jboss.resteasy.core.ResteasyContext;
import org.jboss.resteasy.resteasy_jaxrs.i18n.LogMessages;
import org.jboss.resteasy.spi.AsyncOutputWriter;
import org.jboss.resteasy.spi.AsyncWriterInterceptor;
import org.jboss.resteasy.spi.AsyncWriterInterceptorContext;
import org.jboss.resteasy.spi.ResteasyConfiguration;
import org.jboss.resteasy.util.CommitHeaderOutputStream;

import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * <p>
 *  JSONP is an alternative to normal AJAX requests. Instead of using a XMLHttpRequest a script tag is added to the DOM.
 *  The browser will call the corresponding URL and download the JavaScript. The server creates a response which looks like a
 *  method call. The parameter is the body of the request. The name of the method to call is normally passed as query parameter.
 *  The method has to be present in the current JavaScript environment.
 * </p>
 * <p>
 *  Jackson JSON processor can produce such an response. This interceptor checks if the media type is a JavaScript one if there is a query
 *  parameter with the method name. The default name of this query parameter is "callback". So this interceptor is compatible with
 *  <a href="http://api.jquery.com/jQuery.ajax/">jQuery</a>.
 * </p>
 * <p>
 *  It is possible to wrap the generated javascript function call in a try-catch block.
 *  You can enable it either by setting the {@link #wrapInTryCatch} property of the provider instance to {@code true}
 *  or by setting the {@code resteasy.jsonp.silent} context-param to true:
 * </p>
 * <pre>
 *  {@code
 *  <context-param>
 *   <param-name>resteasy.jsonp.silent</param-name>
 *   <param-value>true</param-value>
 *  </context-param>
 *  }
 * </pre>
 *
 * @author <a href="mailto:holger.morch@nokia.com">Holger Morch</a>
 * @version $Revision: 1 $
 */
@Provider
@ConstrainedTo(RuntimeType.SERVER)
public class Jackson2JsonpInterceptor implements AsyncWriterInterceptor{

   /**
    * "text/javascript" media type. Default media type of script tags.
    */
   public static final MediaType TEXT_JAVASCRIPT_MEDIA_TYPE = new MediaType("text", "javascript");

   /**
    * "application/javascript" media type.
    */
   public static final MediaType APPLICATION_JAVASCRIPT_MEDIA_TYPE = new MediaType("application", "javascript");

   /**
    * "text/json" media type.
    */
   public static final MediaType TEXT_JSON_TYPE = new MediaType("text", "json");

   /**
    * "application/*+json" media type.
    */
   public static final MediaType APPLICATION_PLUS_JSON_TYPE = new MediaType("application", "*+json");

   /**
    * Default name of the query parameter with the method name.
    */
   public static final String DEFAULT_CALLBACK_QUERY_PARAMETER = "callback";

   /**
    * If response media type is one of this jsonp response may be created.
    */
   public static final MediaTypeMap<String> jsonpCompatibleMediaTypes = new MediaTypeMap<String>();

   /**
    * Default {@link ObjectMapper} for type resolution. Used if none is provided by {@link Providers}.
    */
   protected static final ObjectMapper DEFAULT_MAPPER;

   static {
      DEFAULT_MAPPER = new ObjectMapper();
      DEFAULT_MAPPER.setPolymorphicTypeValidator(new WhiteListPolymorphicTypeValidatorBuilder().build());
      jsonpCompatibleMediaTypes.add(MediaType.APPLICATION_JSON  , MediaType.APPLICATION_JSON_TYPE.toString());
      jsonpCompatibleMediaTypes.add(APPLICATION_JAVASCRIPT_MEDIA_TYPE, APPLICATION_JAVASCRIPT_MEDIA_TYPE.toString());
      jsonpCompatibleMediaTypes.add(APPLICATION_PLUS_JSON_TYPE       , APPLICATION_PLUS_JSON_TYPE.toString());
      jsonpCompatibleMediaTypes.add(TEXT_JSON_TYPE                   , TEXT_JSON_TYPE.toString());
      jsonpCompatibleMediaTypes.add(TEXT_JAVASCRIPT_MEDIA_TYPE       , TEXT_JAVASCRIPT_MEDIA_TYPE.toString());
   }

   private UriInfo uri;

   private String callbackQueryParameter = DEFAULT_CALLBACK_QUERY_PARAMETER;

   private boolean wrapInTryCatch = false;

   public Jackson2JsonpInterceptor() {
      ResteasyConfiguration context = ResteasyContext.getContextData(ResteasyConfiguration.class);
      if (context != null) {
         wrapInTryCatch = Boolean.parseBoolean(context.getParameter("resteasy.jsonp.silent"));
         enabled = Boolean.parseBoolean(context.getParameter("resteasy.jsonp.enable"));
      }
   }

   /**
    * The {@link ObjectMapper} used to create typing information.
    */
   protected ObjectMapper objectMapper;

   /**
    * The {@link Providers} used to retrieve the {@link #objectMapper} from.
    */
   protected Providers providers;

   /**
    * Is this interceptor enabled.
    */
   private boolean enabled = false;

   /**
    * This subclass of {@link CommitHeaderOutputStream} overrides the {@link #close()} method so it would commit
    * the headers only, without actually calling the {@link #close()} method of the delegate {@link OutputStream}
    */
   private static class DoNotCloseDelegateOutputStream extends BufferedOutputStream {

      DoNotCloseDelegateOutputStream(final OutputStream delegate) {
         super(delegate);
      }

      @Override
      public void close() throws IOException {
         flush();
      }
   }

   /**
    * {@inheritDoc}
    */
   @Override
   public void aroundWriteTo(WriterInterceptorContext context) throws IOException, WebApplicationException {
      LogMessages.LOGGER.debugf("Interceptor : %s,  Method : aroundWriteTo", getClass().getName());

      String function = uri.getQueryParameters().getFirst(callbackQueryParameter);
      if (enabled && function != null && !function.trim().isEmpty() && !jsonpCompatibleMediaTypes.getPossible(context.getMediaType()).isEmpty()){

         OutputStreamWriter writer = new OutputStreamWriter(context.getOutputStream());

         if (wrapInTryCatch) writer.write("try{");
         writer.write(function + "(");
         writer.flush();

         // Disable the close method before calling context.proceed()
         OutputStream old = context.getOutputStream();
         DoNotCloseDelegateOutputStream wrappedOutputStream = new DoNotCloseDelegateOutputStream(old);
         context.setOutputStream(wrappedOutputStream);

         try {
            context.proceed();
            wrappedOutputStream.flush();
            writer.write(")");
            if (wrapInTryCatch) writer.write("}catch(e){}");
            writer.flush();
         } finally {
            context.setOutputStream(old);
         }
      } else {
         context.proceed();
      }
   }

   @Override
   public CompletionStage<Void> asyncAroundWriteTo(AsyncWriterInterceptorContext context) {
       LogMessages.LOGGER.debugf("Interceptor : %s,  Method : aroundWriteTo", getClass().getName());

       String function = uri.getQueryParameters().getFirst(callbackQueryParameter);
       if (enabled && function != null && !function.trim().isEmpty() && !jsonpCompatibleMediaTypes.getPossible(context.getMediaType()).isEmpty()){

          AsyncOutputWriter writer = new AsyncOutputWriter(context.getAsyncOutputStream());
          CompletionStage<Void> ret = CompletableFuture.completedFuture(null);
          if (wrapInTryCatch) {
              ret = ret.thenCompose(v -> writer.asyncWrite("try{"));
          }
          ret = ret.thenCompose(v -> writer.asyncWrite(function + "("))
                  .thenCompose(v -> writer.asyncFlush())
                  .thenCompose(v -> context.asyncProceed())
                  .thenCompose(v -> writer.asyncFlush())
                  .thenCompose(v -> writer.asyncWrite(")"));

          if (wrapInTryCatch) {
              ret = ret.thenCompose(v -> writer.asyncWrite("}catch(e){}"));
          }
          return ret.thenCompose(v -> writer.asyncFlush());
       } else {
          return context.asyncProceed();
       }
   }

   /**
    * Search for an {@link ObjectMapper} for the given class and mediaType
    *
    * @param type the {@link Class} to serialize
    * @param mediaType the response {@link MediaType}
    * @return the {@link ObjectMapper}
    */
   protected ObjectMapper getObjectMapper(Class<?> type, MediaType mediaType)
   {
      if (objectMapper != null) {
         return objectMapper;
      }

      if (providers != null) {
         ContextResolver<ObjectMapper> resolver = providers.getContextResolver(ObjectMapper.class, mediaType);
         if (resolver == null) {
            resolver = providers.getContextResolver(ObjectMapper.class, null);
         }
         if (resolver != null) {
            return resolver.getContext(type);
         }
      }

      return DEFAULT_MAPPER;
   }


   /**
    * Setter used by RESTeasy to provide the {@link UriInfo}.
    *
    * @param uri the uri to set
    */
   @Context
   public void setUri(UriInfo uri) {
      this.uri = uri;
   }

   /**
    * Setter used by RESTeasy to provide the {@link Providers}
    *
    * @param providers
    */
   @Context
   public void setProviders(Providers providers) {
      this.providers = providers;
   }

   /**
    * Set an fix {@link ObjectMapper}. If this is not set {@link Providers} are used for lookup. If there are is none too, use a default one.
    *
    * @param objectMapper
    */
   public void setObjectMapper(ObjectMapper objectMapper) {
      this.objectMapper = objectMapper;
   }

   /**
    * Get the name of the query parameter which contains the JavaScript method name. Default: callback.
    *
    * @return the callbackQueryParameter
    */
   public String getCallbackQueryParameter() {
      return callbackQueryParameter;
   }

   /**
    * Set callback query parameter.
    *
    * @see #getCallbackQueryParameter()
    * @param callbackQueryParameter the callbackQueryParameter to set
    */
   public void setCallbackQueryParameter(String callbackQueryParameter) {
      this.callbackQueryParameter = callbackQueryParameter;
   }

   /**
    * Check is the JSONP callback will be wrapped with try-catch block
    *
    * @return true if try-catch block is generated; false otherwise
    */
   public boolean isWrapInTryCatch() {
      return wrapInTryCatch;
   }

   /**
    * Enables or disables wrapping the JSONP callback try try-catch block
    *
    * @param wrapInTryCatch true if you want to wrap the result with try-catch block; false otherwise
    */
   public void setWrapInTryCatch(boolean wrapInTryCatch) {
      this.wrapInTryCatch = wrapInTryCatch;
   }

}
