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;
    }

}
