package org.jboss.resteasy.core;

import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;

import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.container.ContainerRequestFilter;
import jakarta.ws.rs.container.ResourceContext;
import jakarta.ws.rs.core.HttpHeaders;
import jakarta.ws.rs.core.Request;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import jakarta.ws.rs.ext.Providers;

import org.jboss.resteasy.core.interception.jaxrs.PreMatchContainerRequestContext;
import org.jboss.resteasy.plugins.server.Cleanable;
import org.jboss.resteasy.plugins.server.Cleanables;
import org.jboss.resteasy.resteasy_jaxrs.i18n.LogMessages;
import org.jboss.resteasy.resteasy_jaxrs.i18n.Messages;
import org.jboss.resteasy.specimpl.BuiltResponse;
import org.jboss.resteasy.specimpl.RequestImpl;
import org.jboss.resteasy.spi.Dispatcher;
import org.jboss.resteasy.spi.Failure;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.HttpRequestPreprocessor;
import org.jboss.resteasy.spi.HttpResponse;
import org.jboss.resteasy.spi.InternalServerErrorException;
import org.jboss.resteasy.spi.Registry;
import org.jboss.resteasy.spi.ResourceInvoker;
import org.jboss.resteasy.spi.ResteasyAsynchronousContext;
import org.jboss.resteasy.spi.ResteasyConfiguration;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.jboss.resteasy.spi.UnhandledException;
import org.jboss.resteasy.tracing.RESTEasyTracingLogger;

/**
 * @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
 * @version $Revision: 1 $
 */
public class SynchronousDispatcher implements Dispatcher {
    protected ResteasyProviderFactory providerFactory;
    protected Registry registry;
    protected List<HttpRequestPreprocessor> requestPreprocessors = new ArrayList<HttpRequestPreprocessor>();
    @SuppressWarnings("rawtypes")
    protected Map<Class, Object> defaultContextObjects = new HashMap<Class, Object>();
    protected Set<String> unwrappedExceptions = new HashSet<String>();
    protected boolean bufferExceptionEntityRead = false;
    protected boolean bufferExceptionEntity = true;

    {
        // This is to make sure LogMessages are preloaded as profiler shows a runtime hit
        // This will also insure that this initialization is done at static init time when loaded with Graal
        // Not a big deal if you remove this.
        @SuppressWarnings("unused")
        LogMessages preload = LogMessages.LOGGER;
    }

    public SynchronousDispatcher(final ResteasyProviderFactory providerFactory) {
        this.providerFactory = providerFactory;
        this.registry = new ResourceMethodRegistry(providerFactory);
        defaultContextObjects.put(Providers.class, providerFactory);
        defaultContextObjects.put(Registry.class, registry);
        defaultContextObjects.put(Dispatcher.class, this);
        defaultContextObjects.put(InternalDispatcher.class, InternalDispatcher.getInstance());
    }

    public SynchronousDispatcher(final ResteasyProviderFactory providerFactory, final ResourceMethodRegistry registry) {
        this(providerFactory);
        this.registry = registry;
        defaultContextObjects.put(Registry.class, registry);
    }

    public ResteasyProviderFactory getProviderFactory() {
        return providerFactory;
    }

    public Registry getRegistry() {
        return registry;
    }

    public Map<Class, Object> getDefaultContextObjects() {
        return defaultContextObjects;
    }

    public Set<String> getUnwrappedExceptions() {
        return unwrappedExceptions;
    }

    /*
     * TODO: refactor this method
     * This only used by org.jboss.restesy.springmvc.ResteasyHandlerMapping
     * And most of the code is same with the other preprocess method.
     * We should consider to refactor this method to reuse part of the code with
     * another one.
     */
    public Response preprocess(HttpRequest request) {
        RESTEasyTracingLogger.initTracingSupport(providerFactory, request);
        Response aborted = null;

        RESTEasyTracingLogger tracingLogger = RESTEasyTracingLogger.getInstance(request);

        try {
            final long totalTimestamp = tracingLogger.timestamp("PRE_MATCH_SUMMARY");
            for (HttpRequestPreprocessor preprocessor : this.requestPreprocessors) {
                final long timestamp = tracingLogger.timestamp("PRE_MATCH");
                preprocessor.preProcess(request);
                tracingLogger.logDuration("PRE_MATCH", timestamp, preprocessor.getClass().toString());
            }
            tracingLogger.logDuration("PRE_MATCH_SUMMARY", totalTimestamp, this.requestPreprocessors.size());
            ContainerRequestFilter[] requestFilters = providerFactory.getContainerRequestFilterRegistry().preMatch();
            // FIXME: support async
            PreMatchContainerRequestContext requestContext = new PreMatchContainerRequestContext(request, requestFilters, null);
            aborted = requestContext.filter();
        } catch (Exception e) {
            //logger.error("Failed in preprocess, mapping exception", e);
            aborted = new ExceptionHandler(providerFactory, unwrappedExceptions).handleException(request, e);
        }

        return aborted;
    }

    /**
     * Call pre-process ContainerRequestFilters.
     *
     * @param request      http request
     * @param response     http response
     * @param continuation runnable
     */
    protected void preprocess(HttpRequest request, HttpResponse response, Runnable continuation) {
        Response aborted = null;
        PreMatchContainerRequestContext requestContext = null;

        RESTEasyTracingLogger tracingLogger = RESTEasyTracingLogger.getInstance(request);

        try {
            final long totalTimestamp = tracingLogger.timestamp("PRE_MATCH_SUMMARY");
            for (HttpRequestPreprocessor preprocessor : this.requestPreprocessors) {
                final long timestamp = tracingLogger.timestamp("PRE_MATCH");
                preprocessor.preProcess(request);
                tracingLogger.logDuration("PRE_MATCH", timestamp, preprocessor.getClass().toString());
            }
            tracingLogger.logDuration("PRE_MATCH_SUMMARY", totalTimestamp, this.requestPreprocessors.size());
            ContainerRequestFilter[] requestFilters = providerFactory.getContainerRequestFilterRegistry().preMatch();
            requestContext = new PreMatchContainerRequestContext(request, requestFilters,
                    () -> {
                        continuation.run();
                        return null;
                    });
            aborted = requestContext.filter();
        } catch (Exception e) {
            //logger.error("Failed in preprocess, mapping exception", e);
            // we only want to catch exceptions happening in the filters, not in the continuation
            if (requestContext == null || !requestContext.startedContinuation()) {
                writeException(request, response, e, t -> {
                });

                return;
            } else {
                rethrow(e);
            }
        }
        if (aborted != null) {
            tracingLogger.log("FINISHED", response.getStatus());
            tracingLogger.flush(response.getOutputHeaders());
            writeResponse(request, response, aborted);
            return;
        }
    }

    @SuppressWarnings("unchecked")
    public static <T extends Throwable> void rethrow(Throwable t) throws T {
        throw (T) t;
    }

    @Deprecated
    public void writeException(HttpRequest request, HttpResponse response, Throwable e) {
        writeException(request, response, e, t -> {
        });
    }

    public void writeException(HttpRequest request, HttpResponse response, Throwable e, Consumer<Throwable> onComplete) {
        if (!bufferExceptionEntityRead) {
            bufferExceptionEntityRead = true;
            ResteasyConfiguration context = ResteasyContext.getContextData(ResteasyConfiguration.class);
            if (context != null) {
                String s = context.getParameter("resteasy.buffer.exception.entity");
                if (s != null) {
                    bufferExceptionEntity = Boolean.parseBoolean(s);
                }
            }
        }
        if (response.isCommitted() && response.suppressExceptionDuringChunkedTransfer()) {
            LogMessages.LOGGER.debug(Messages.MESSAGES.responseIsCommitted());
            onComplete.accept(null);
            return;
        }
        Response handledResponse = new ExceptionHandler(providerFactory, unwrappedExceptions).handleException(request, e);
        if (handledResponse == null)
            throw new UnhandledException(e);
        if (!bufferExceptionEntity) {
            response.getOutputHeaders().add("resteasy.buffer.exception.entity", "false");
        }
        try {
            ServerResponseWriter.writeNomapResponse(((BuiltResponse) handledResponse), request, response, providerFactory,
                    onComplete);
        } catch (Exception e1) {
            throw new UnhandledException(e1);
        } finally {
            RESTEasyTracingLogger tracingLogger = RESTEasyTracingLogger.getInstance(request);
            tracingLogger.log("FINISHED", response.getStatus());
            tracingLogger.flush(response.getOutputHeaders());
        }
    }

    public void invoke(HttpRequest request, HttpResponse response) {
        RESTEasyTracingLogger.initTracingSupport(providerFactory, request);
        RESTEasyTracingLogger.logStart(request);

        try {
            pushContextObjects(request, response);
            preprocess(request, response, () -> {
                ResourceInvoker invoker = null;
                try {
                    try {
                        invoker = getInvoker(request);
                    } catch (Exception exception) {
                        //logger.error("getInvoker() failed mapping exception", exception);
                        writeException(request, response, exception, t -> {
                        });
                        return;
                    }
                    invoke(request, response, invoker);
                } finally {
                    // we're probably clearing it twice but still required
                    clearContextData();
                }
            });
        } finally {
            clearContextData();
        }
    }

    /**
     * Propagate NotFoundException. This is used for Filters.
     *
     * @param request  http request
     * @param response http response
     */
    public void invokePropagateNotFound(HttpRequest request, HttpResponse response) throws NotFoundException {
        try {
            pushContextObjects(request, response);
            preprocess(request, response, () -> {
                ResourceInvoker invoker = null;
                try {
                    try {
                        invoker = getInvoker(request);
                    } catch (Exception failure) {
                        if (failure instanceof NotFoundException) {
                            throw ((NotFoundException) failure);
                        } else {
                            //logger.error("getInvoker() failed mapping exception", failure);
                            writeException(request, response, failure, t -> {
                            });
                            return;
                        }
                    }
                    invoke(request, response, invoker);
                } finally {
                    // we're probably clearing it twice but still required
                    clearContextData();
                }
            });
        } finally {
            clearContextData();
        }

    }

    public ResourceInvoker getInvoker(HttpRequest request)
            throws Failure {
        LogMessages.LOGGER.pathInfo(request.getUri().getPath());
        if (!request.isInitial()) {
            throw new InternalServerErrorException(Messages.MESSAGES.isNotInitialRequest(request.getUri().getPath()));
        }
        ResourceInvoker invoker = registry.getResourceInvoker(request);
        if (invoker == null) {
            throw new NotFoundException(Messages.MESSAGES.unableToFindJaxRsResource(request.getUri().getPath()));
        }
        RESTEasyTracingLogger logger = RESTEasyTracingLogger.getInstance(request);
        logger.log("MATCH_RESOURCE", invoker);
        logger.log("MATCH_RESOURCE_METHOD", invoker.getMethod());
        return invoker;
    }

    @SuppressWarnings("unchecked")
    public void pushContextObjects(final HttpRequest request, final HttpResponse response) {
        @SuppressWarnings("rawtypes")
        Map contextDataMap = ResteasyContext.getContextDataMap();
        contextDataMap.put(HttpRequest.class, request);
        contextDataMap.put(HttpResponse.class, response);
        contextDataMap.put(HttpHeaders.class, request.getHttpHeaders());
        contextDataMap.put(UriInfo.class, request.getUri());
        contextDataMap.put(Request.class, new RequestImpl(request, response));
        contextDataMap.put(ResteasyAsynchronousContext.class, request.getAsyncContext());
        ResourceContext resourceContext = new ResourceContext() {
            @Override
            public <T> T getResource(Class<T> resourceClass) {
                return providerFactory.injectedInstance(resourceClass, request, response);
            }

            @Override
            public <T> T initResource(T resource) {
                providerFactory.injectProperties(resource, request, response);
                return resource;
            }
        };
        contextDataMap.put(ResourceContext.class, resourceContext);

        contextDataMap.putAll(defaultContextObjects);
        contextDataMap.put(Cleanables.class, new Cleanables());
        contextDataMap.put(PostResourceMethodInvokers.class, new PostResourceMethodInvokers());
    }

    public Response internalInvocation(HttpRequest request, HttpResponse response, Object entity) {
        // be extra careful in the clean up process. Only pop if there was an
        // equivalent push.
        ResteasyContext.addContextDataLevel();
        boolean pushedBody = false;
        try {
            MessageBodyParameterInjector.pushBody(entity);
            pushedBody = true;
            ResourceInvoker invoker = getInvoker(request);
            if (invoker != null) {
                pushContextObjects(request, response);
                return execute(request, response, invoker);
            }

            // this should never happen, since getInvoker should throw an exception
            // if invoker is null
            return null;
        } finally {
            ResteasyContext.removeContextDataLevel();
            if (pushedBody) {
                MessageBodyParameterInjector.popBody();
            }
        }
    }

    public void clearContextData() {
        Map<Class<?>, Object> map = ResteasyContext.getContextDataMap(false);
        Cleanables cleanables = map != null ? (Cleanables) map.get(Cleanables.class) : null;
        if (cleanables != null) {
            for (Iterator<Cleanable> it = cleanables.getCleanables().iterator(); it.hasNext();) {
                try {
                    it.next().clean();
                } catch (Exception e) {
                    // Empty
                }
            }
            ResteasyContext.clearContextData();
        }
        // just in case there were internalDispatches that need to be cleaned up
        MessageBodyParameterInjector.clearBodies();
    }

    /**
     * Return a response wither from an invoke or exception handling.
     *
     * @param request  http request
     * @param response http response
     * @param invoker  resource invoker
     * @return response
     */
    public Response execute(HttpRequest request, HttpResponse response, ResourceInvoker invoker) {
        Response jaxrsResponse = null;
        try {
            RESTEasyTracingLogger logger = RESTEasyTracingLogger.getInstance(request);
            logger.log("DISPATCH_RESPONSE", jaxrsResponse);

            request.getAsyncContext().initialRequestStarted();
            jaxrsResponse = invoker.invoke(request, response);
            request.getAsyncContext().initialRequestEnded();

            if (request.getAsyncContext().isSuspended()) {
                /**
                 * Callback by the initial calling thread. This callback will probably do nothing in an asynchronous environment
                 * but will be used to simulate AsynchronousResponse in vanilla Servlet containers that do not support
                 * asychronous HTTP.
                 *
                 */
                request.getAsyncContext().getAsyncResponse().initialRequestThreadFinished();
                jaxrsResponse = null; // we're handing response asynchronously
            }
        } catch (CompletionException e) {
            //logger.error("invoke() failed mapping exception", e);
            jaxrsResponse = new ExceptionHandler(providerFactory, unwrappedExceptions).handleException(request, e.getCause());
            if (jaxrsResponse == null)
                throw new UnhandledException(e.getCause());
        } catch (Exception e) {
            //logger.error("invoke() failed mapping exception", e);
            jaxrsResponse = new ExceptionHandler(providerFactory, unwrappedExceptions).handleException(request, e);
            if (jaxrsResponse == null)
                throw new UnhandledException(e);
        }
        return jaxrsResponse;
    }

    /**
     * Invoke and write response.
     *
     * @param request  http request
     * @param response http response
     * @param invoker  resource invoker
     */
    public void invoke(HttpRequest request, HttpResponse response, ResourceInvoker invoker) {

        RESTEasyTracingLogger tracingLogger = RESTEasyTracingLogger.getInstance(request);
        Response jaxrsResponse = null;
        try {
            request.getAsyncContext().initialRequestStarted();
            jaxrsResponse = invoker.invoke(request, response);
            request.getAsyncContext().initialRequestEnded();

            tracingLogger.log("DISPATCH_RESPONSE", jaxrsResponse);

            if (request.getAsyncContext().isSuspended()) {
                /**
                 * Callback by the initial calling thread. This callback will probably do nothing in an asynchronous environment
                 * but will be used to simulate AsynchronousResponse in vanilla Servlet containers that do not support
                 * asychronous HTTP.
                 *
                 */
                request.getAsyncContext().getAsyncResponse().initialRequestThreadFinished();
                jaxrsResponse = null; // we're handing response asynchronously
            }
        } catch (CompletionException e) {
            //logger.error("invoke() failed mapping exception", e);
            writeException(request, response, e.getCause(), t -> {
            });
            return;
        } catch (Exception e) {
            //logger.error("invoke() failed mapping exception", e);
            invoker.getMethodStatisticsLogger().incFailureCnt();
            writeException(request, response, e, t -> {
            });
            return;
        }

        if (jaxrsResponse != null) {
            writeResponse(request, response, jaxrsResponse);
        }
    }

    @Deprecated
    public void asynchronousDelivery(HttpRequest request, HttpResponse response, Response jaxrsResponse) throws IOException {
        asynchronousDelivery(request, response, jaxrsResponse, t -> {
        });
    }

    public void asynchronousDelivery(HttpRequest request, HttpResponse response, Response jaxrsResponse,
            Consumer<Throwable> onComplete) throws IOException {
        if (jaxrsResponse == null)
            return;
        try {
            pushContextObjects(request, response);
            ServerResponseWriter.writeNomapResponse((BuiltResponse) jaxrsResponse, request, response, providerFactory,
                    onComplete);
        } finally {
            ResteasyContext.removeContextDataLevel();
        }
    }

    public void unhandledAsynchronousException(HttpResponse response, Throwable ex) {
        LogMessages.LOGGER.unhandledAsynchronousException(ex);
        // unhandled exceptions need to be processed as they can't be thrown back to the servlet container
        if (!response.isCommitted()) {
            try {
                response.reset();
                response.sendError(500);
            } catch (IOException e) {

            }
        }
    }

    @Deprecated
    public void asynchronousExceptionDelivery(HttpRequest request, HttpResponse response, Throwable exception) {
        asynchronousExceptionDelivery(request, response, exception, t -> {
        });
    }

    public void asynchronousExceptionDelivery(HttpRequest request, HttpResponse response, Throwable exception,
            Consumer<Throwable> onComplete) {
        try {
            pushContextObjects(request, response);
            writeException(request, response, exception, t -> {
                if (t != null)
                    unhandledAsynchronousException(response, t);
                onComplete.accept(null);
                ResteasyContext.removeContextDataLevel();
            });
        } catch (Throwable ex) {
            unhandledAsynchronousException(response, ex);
            onComplete.accept(ex);
        }
    }

    protected void writeResponse(HttpRequest request, HttpResponse response, Response jaxrsResponse) {
        try {
            ServerResponseWriter.writeNomapResponse((BuiltResponse) jaxrsResponse, request, response, providerFactory,
                    t -> {
                        if (t != null) {
                            // if we're async we can't trust UnhandledException to be caught
                            if (request.getAsyncContext().isSuspended()
                                    && !request.getAsyncContext().isOnInitialRequest()) {
                                try {
                                    writeException(request, response, t, t2 -> {
                                    });
                                } catch (Throwable ex) {
                                    unhandledAsynchronousException(response, ex);
                                }
                            } else {
                                rethrow(t);
                            }
                        }
                    });
        } catch (Exception e) {
            writeException(request, response, e, t -> {
                // If there is an error, rethrow the original error suppressing the new error.
                if (t != null) {
                    e.addSuppressed(t);
                    rethrow(e);
                }
            });
        } finally {
            RESTEasyTracingLogger tracingLogger = RESTEasyTracingLogger.getInstance(request);
            tracingLogger.log("FINISHED", response.getStatus());
            tracingLogger.flush(response.getOutputHeaders());
        }
    }

    public void addHttpPreprocessor(HttpRequestPreprocessor httpPreprocessor) {
        requestPreprocessors.add(httpPreprocessor);
    }

}
