/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 2010-2013 Oracle and/or its affiliates. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License.  You can
 * obtain a copy of the License at
 * http://glassfish.java.net/public/CDDL+GPL_1_1.html
 * or packager/legal/LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at packager/legal/LICENSE.txt.
 *
 * GPL Classpath Exception:
 * Oracle designates this particular file as subject to the "Classpath"
 * exception as provided by Oracle in the GPL Version 2 section of the License
 * file that accompanied this code.
 *
 * Modifications:
 * If applicable, add the following below the License Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyright [year] [name of copyright owner]"
 *
 * Contributor(s):
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 */
package com.sun.jersey.spi.container;

import com.sun.jersey.api.MessageException;
import com.sun.jersey.core.header.OutBoundHeaders;
import com.sun.jersey.api.Responses;
import com.sun.jersey.api.container.MappableContainerException;
import com.sun.jersey.api.core.HttpResponseContext;
import com.sun.jersey.api.core.TraceInformation;
import com.sun.jersey.core.reflection.ReflectionHelper;
import com.sun.jersey.core.spi.factory.ResponseImpl;
import com.sun.jersey.server.impl.uri.rules.HttpMethodRule;
import com.sun.jersey.spi.MessageBodyWorkers;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Type;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.GenericEntity;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.Response.StatusType;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.MessageBodyWriter;
import javax.ws.rs.ext.RuntimeDelegate;
import javax.ws.rs.ext.RuntimeDelegate.HeaderDelegate;

/**
 * An out-bound HTTP response to be processed by the web application.
 * <p>
 * Containers instantiate, or inherit, and provide an instance to the
 * {@link WebApplication}.
 *
 * @author Paul Sandoz (paul.sandoz at oracle.com)
 */
public class ContainerResponse implements HttpResponseContext {
    private static final Annotation[] EMPTY_ANNOTATIONS = new Annotation[0];

    private static final Logger LOGGER = Logger.getLogger(ContainerResponse.class.getName());

    private static final RuntimeDelegate rd = RuntimeDelegate.getInstance();

    private final WebApplication wa;

    private ContainerRequest request;

    private ContainerResponseWriter responseWriter;

    private Response response;

    private Throwable mappedThrowable;

    private StatusType statusType;

    private MultivaluedMap<String, Object> headers;

    private Object originalEntity;

    private Object entity;

    private Type entityType;

    private boolean isCommitted;

    private CommittingOutputStream out;

    private Annotation[] annotations = EMPTY_ANNOTATIONS;

    private final class CommittingOutputStream extends OutputStream {
        private final long size;

        private OutputStream o;

        CommittingOutputStream(long size) {
            this.size = size;
        }

        @Override
        public void write(byte b[]) throws IOException {
            commitWrite();
            o.write(b);
        }

        @Override
        public void write(byte b[], int off, int len) throws IOException {
            commitWrite();
            o.write(b, off, len);
        }

        public void write(int b) throws IOException {
            commitWrite();
            o.write(b);
        }

        @Override
        public void flush() throws IOException {
            commitWrite();
            o.flush();
        }

        @Override
        public void close() throws IOException {
            commitClose();
            o.close();
        }

        private void commitWrite() throws IOException {
            if (!isCommitted) {
                if (getStatus() == 204)
                    setStatus(200);
                isCommitted = true;
                o = responseWriter.writeStatusAndHeaders(size, ContainerResponse.this);
            }
        }

        private void commitClose() throws IOException {
            if (!isCommitted) {
                isCommitted = true;
                o = responseWriter.writeStatusAndHeaders(-1, ContainerResponse.this);
            }
        }
    }

    /**
     * Instantiate a new ContainerResponse.
     *
     * @param wa the web application.
     * @param request the container request associated with this response.
     * @param responseWriter the response writer
     */
    public ContainerResponse(
            WebApplication wa,
            ContainerRequest request,
            ContainerResponseWriter responseWriter) {
        this.wa = wa;
        this.request = request;
        this.responseWriter = responseWriter;
        this.statusType = Status.NO_CONTENT;
    }

    /*package */ ContainerResponse(
            ContainerResponse acr) {
        this.wa = acr.wa;
    }

    // ContainerResponse

    /**
     * Convert a header value, represented as a general object, to the
     * string value.
     * <p>
     * This method defers to {@link RuntimeDelegate#createHeaderDelegate} to
     * obtain a {@link HeaderDelegate} to convert the value to a string. If
     * a {@link HeaderDelegate} is not found then the <code>toString</code>
     * is utilized.
     * <p>
     * Containers may use this method to convert the header values obtained
     * from the {@link #getHttpHeaders}
     *
     * @param headerValue the header value as an object
     * @return the string value
     */
    public static String getHeaderValue(Object headerValue) {
        HeaderDelegate hp = rd.createHeaderDelegate(headerValue.getClass());

        return (hp != null) ? hp.toString(headerValue) : headerValue.toString();
    }

    /**
     * Write the response.
     * <p>
     * The status and headers will be written by calling the method
     * {@link ContainerResponseWriter#writeStatusAndHeaders} on the provided
     * {@link ContainerResponseWriter} instance. The {@link OutputStream}
     * returned from that method call is used to write the entity (if any)
     * to that {@link OutputStream}. An appropriate {@link MessageBodyWriter}
     * will be found to write the entity.
     *
     * @throws WebApplicationException if {@link MessageBodyWriter} cannot be
     *         found for the entity with a 500 (Internal Server error) response.
     * @throws java.io.IOException if there is an error writing the entity
     */
    public void write() throws IOException {
        if (isCommitted)
            return;

        if (request.isTracingEnabled()) {
            configureTrace(responseWriter);
        }

        if (entity == null) {
            isCommitted = true;
            responseWriter.writeStatusAndHeaders(-1, this);
            responseWriter.finish();
            return;
        }

        if (!getHttpHeaders().containsKey(HttpHeaders.VARY)) {
            final String varyHeader = (String)request.getProperties().get(ContainerRequest.VARY_HEADER);
            if (varyHeader != null) {
                getHttpHeaders().add(HttpHeaders.VARY, varyHeader);
            }
        }

        MediaType contentType = getMediaType();
        if (contentType == null) {
            contentType = getMessageBodyWorkers().getMessageBodyWriterMediaType(
                    entity.getClass(),
                    entityType,
                    annotations,
                    request.getAcceptableMediaTypes());
            if (contentType == null ||
                    contentType.isWildcardType() || contentType.isWildcardSubtype())
                contentType = MediaType.APPLICATION_OCTET_STREAM_TYPE;

            getHttpHeaders().putSingle(HttpHeaders.CONTENT_TYPE, contentType);
        }

        final MessageBodyWriter writer = getMessageBodyWorkers().getMessageBodyWriter(
                entity.getClass(), entityType,
                annotations, contentType);

        if (writer == null) {
            String message = "A message body writer for Java class " + entity.getClass().getName() +
                    ", and Java type " + entityType +
                    ", and MIME media type " + contentType + " was not found.\n";

            Map<MediaType, List<MessageBodyWriter>> m = getMessageBodyWorkers().
                    getWriters(contentType);
            LOGGER.severe(message + "The registered message body writers compatible with the MIME media type are:\n" +
                    getMessageBodyWorkers().writersToString(m));

            if (request.getMethod().equals("HEAD")) {
                writeHttpHead(-1);
            } else {
                throw new WebApplicationException(new MessageException(message), 500);
            }
        } else {
            final long size = writer.getSize(entity, entity.getClass(), entityType, annotations, contentType);

            if (request.getMethod().equals("HEAD")) {
                writeHttpHead(size);
            } else {
                if (request.isTracingEnabled()) {
                    request.trace(String.format("matched message body writer: %s, \"%s\" -> %s",
                            ReflectionHelper.objectToString(entity),
                            contentType,
                            ReflectionHelper.objectToString(writer)));
                }
                if (out == null) {
                    out = new CommittingOutputStream(size);
                }

                writer.writeTo(entity, entity.getClass(), entityType, annotations, contentType, getHttpHeaders(), out);

                if (!isCommitted) {
                    isCommitted = true;
                    responseWriter.writeStatusAndHeaders(-1, this);
                }
            }
        }

        responseWriter.finish();
    }

    /**
     * Handle HTTP HEAD method (headers, ..) during writing a response.
     *
     * @param size size of a response entity.
     * @throws IOException if an error occurred when writing out the status and headers or obtaining the output stream.
     */
    private void writeHttpHead(final long size) throws IOException {
        if (size != -1) {
            getHttpHeaders().putSingle(HttpHeaders.CONTENT_LENGTH, Long.toString(size));
        }

        isCommitted = true;
        responseWriter.writeStatusAndHeaders(size, this);

        // Close the InputStream if the entity is an instance of this type.
        if (entity instanceof InputStream) {
            ((InputStream)entity).close();
        }
    }

    private void configureTrace(final ContainerResponseWriter crw) {
        final TraceInformation ti = (TraceInformation)request.getProperties().
                get(TraceInformation.class.getName());
        setContainerResponseWriter(new ContainerResponseWriter() {
            public OutputStream writeStatusAndHeaders(long contentLength,
                                                      ContainerResponse response) throws IOException {
                ti.addTraceHeaders();
                return crw.writeStatusAndHeaders(contentLength, response);
            }

            public void finish() throws IOException {
                crw.finish();
            }
        });
    }

    /**
     * Reset the response to 204 (No content) with no headers.
     */
    public void reset() {
        setResponse(Responses.noContent().build());
    }

    /**
     * Get the container request.
     *
     * @return the container request.
     */
    public ContainerRequest getContainerRequest() {
        return request;
    }

    /**
     * Set the container request.
     *
     * @param request the container request.
     */
    public void setContainerRequest(ContainerRequest request) {
        this.request = request;
    }

    /**
     * Get the container response writer.
     *
     * @return the container response writer
     */
    public ContainerResponseWriter getContainerResponseWriter() {
        return responseWriter;
    }

    /**
     * Set the container response writer.
     *
     * @param responseWriter the container response writer
     */
    public void setContainerResponseWriter(ContainerResponseWriter responseWriter) {
        this.responseWriter = responseWriter;
    }

    /**
     * Get the message body workers.
     *
     * @return the message body workers.
     */
    public MessageBodyWorkers getMessageBodyWorkers() {
        return wa.getMessageBodyWorkers();
    }

    /**
     * Map the cause of a mappable container exception to a response.
     * <p>
     * If the cause cannot be mapped and then that cause is re-thrown
     * if a runtime exception otherwise the mappable container exception is
     * re-thrown.
     *
     * @param e the mappable container exception whose cause will be mapped to
     *        a response.
     */
    public void mapMappableContainerException(MappableContainerException e) {
        Throwable cause = e.getCause();

        if (cause instanceof WebApplicationException) {
            mapWebApplicationException((WebApplicationException)cause);
        } else if (!mapException(cause)) {
            if (cause instanceof RuntimeException) {
                LOGGER.log(Level.SEVERE, "The RuntimeException could not be mapped to a response, " +
                        "re-throwing to the HTTP container", cause);
                throw (RuntimeException)cause;
            } else {
                LOGGER.log(Level.SEVERE, "The exception contained within " +
                        "MappableContainerException could not be mapped to a response, " +
                        "re-throwing to the HTTP container", cause);
                throw e;
            }
        }
    }

    /**
     * Map a web application exception to a response.
     *
     * @param e the web application exception.
     */
    public void mapWebApplicationException(WebApplicationException e) {
        if (e.getResponse().getEntity() != null) {
            wa.getResponseListener().onError(
                    Thread.currentThread().getId(),
                    e
            );
            onException(e, e.getResponse(), false);
        } else {
            if (!mapException(e)) {
                onException(e, e.getResponse(), false);
            }
        }
    }

    /**
     * Map an exception to a response.
     *
     * @param e the exception.
     * @return true if the exception was mapped, otherwise false.
     */
    public boolean mapException(Throwable e) {
        ExceptionMapper em = wa.getExceptionMapperContext().find(e.getClass());
        if (em == null) {
            wa.getResponseListener().onError(
                    Thread.currentThread().getId(),
                    e
            );

            return false;
        }

        wa.getResponseListener().onMappedException(
                Thread.currentThread().getId(),
                e,
                em
        );

        if (request.isTracingEnabled()) {
            request.trace(String.format("matched exception mapper: %s -> %s",
                    ReflectionHelper.objectToString(e),
                    ReflectionHelper.objectToString(em)));
        }

        try {
            Response r = em.toResponse(e);
            if (r == null)
                r = Response.noContent().build();
            onException(e, r, true);
        } catch (MappableContainerException ex) {
            // If the exception mapper throws a MappableContainerException then
            // rethrow it to the HTTP container
            throw ex;
        } catch (RuntimeException ex) {
            LOGGER.severe("Exception mapper " + em +
                    " for Throwable " + e +
                    " threw a RuntimeException when " +
                    "attempting to obtain the response");
            Response r = Response.serverError().build();
            onException(ex, r, false);
        }
        return true;
    }

    private void onException(Throwable e, Response r, boolean mapped) {
        if (request.isTracingEnabled()) {
            Response.Status s = Response.Status.fromStatusCode(r.getStatus());
            if (s != null) {
                request.trace(String.format("mapped exception to response: %s -> %d (%s)",
                        ReflectionHelper.objectToString(e),
                        r.getStatus(),
                        s.getReasonPhrase()));
            } else {
                request.trace(String.format("mapped exception to response: %s -> %d",
                        ReflectionHelper.objectToString(e),
                        r.getStatus()));
            }
        }

        if (!mapped && r.getStatus() >= 500) {
            logException(e, r, Level.SEVERE);
        } else if (LOGGER.isLoggable(Level.FINE)) {
            logException(e, r, Level.FINE);
        }

        setResponse(r);

        this.mappedThrowable = e;

        if (getEntity() != null &&
                getHttpHeaders().getFirst(HttpHeaders.CONTENT_TYPE) == null) {
            Object m = request.getProperties().get(HttpMethodRule.CONTENT_TYPE_PROPERTY);
            if (m != null) {
                request.getProperties().remove(HttpMethodRule.CONTENT_TYPE_PROPERTY);
                getHttpHeaders().putSingle(HttpHeaders.CONTENT_TYPE, m);
            }
        }
    }

    private void logException(Throwable e, Response r, Level l) {
        Response.Status s = Response.Status.fromStatusCode(r.getStatus());
        if (s != null) {
            LOGGER.log(l,
                    "Mapped exception to response: " + r.getStatus() + " (" + s.getReasonPhrase() + ")",
                    e);
        } else {
            LOGGER.log(l,
                    "Mapped exception to response: " + r.getStatus(),
                    e);
        }
    }

    // HttpResponseContext

    @Override
    public Response getResponse() {
        if (response == null) {
            setResponse(null);
        }

        return response;
    }

    @Override
    public void setResponse(Response response) {
        this.isCommitted = false;
        this.out = null;
        this.response = response = (response != null) ? response : Responses.noContent().build();
        this.mappedThrowable = null;

        if (response instanceof ResponseImpl) {
            final ResponseImpl responseImpl = (ResponseImpl)response;
            setStatusType(responseImpl.getStatusType());
            setHeaders(response.getMetadata());
            setEntity(responseImpl.getEntity(), responseImpl.getEntityType());
        } else {
            setStatus(response.getStatus());
            setHeaders(response.getMetadata());
            setEntity(response.getEntity());
        }
    }

    @Override
    public boolean isResponseSet() {
        return response != null;
    }

    @Override
    public Throwable getMappedThrowable() {
        return mappedThrowable;
    }

    @Override
    public StatusType getStatusType() {
        return statusType;
    }

    @Override
    public void setStatusType(StatusType statusType) {
        this.statusType = statusType;
    }

    @Override
    public int getStatus() {
        return statusType.getStatusCode();
    }

    @Override
    public void setStatus(int status) {
        this.statusType = ResponseImpl.toStatusType(status);
    }

    @Override
    public Object getEntity() {
        return entity;
    }

    @Override
    public Type getEntityType() {
        return entityType;
    }

    @Override
    public Object getOriginalEntity() {
        return originalEntity;
    }

    @Override
    public void setEntity(Object entity) {
        setEntity(entity, (entity == null) ? null : entity.getClass());
    }

    public void setEntity(Object entity, Type entityType) {
        this.originalEntity = this.entity = entity;
        this.entityType = entityType;
        if (this.entity instanceof GenericEntity) {
            final GenericEntity ge = (GenericEntity)this.entity;
            this.entity = ge.getEntity();
            this.entityType = ge.getType();
        }
    }

    @Override
    public Annotation[] getAnnotations() {
        return annotations;
    }

    @Override
    public void setAnnotations(Annotation[] annotations) {
        this.annotations = (annotations != null) ? annotations : EMPTY_ANNOTATIONS;
    }

    @Override
    public MultivaluedMap<String, Object> getHttpHeaders() {
        if (headers == null)
            headers = new OutBoundHeaders();
        return headers;
    }

    @Override
    public MediaType getMediaType() {
        final Object mediaTypeHeader = getHttpHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
        if (mediaTypeHeader instanceof MediaType) {
            return (MediaType)mediaTypeHeader;
        } else if (mediaTypeHeader != null) {
            return MediaType.valueOf(mediaTypeHeader.toString());
        }

        return null;
    }

    @Override
    public OutputStream getOutputStream() throws IOException {
        if (out == null)
            out = new CommittingOutputStream(-1);

        return out;
    }

    @Override
    public boolean isCommitted() {
        return isCommitted;
    }

    private void setHeaders(MultivaluedMap<String, Object> headers) {
        this.headers = headers;
        Object location = headers.getFirst(HttpHeaders.LOCATION);
        if (location != null) {
            if (location instanceof URI) {
                final URI locationUri = (URI)location;
                if (!locationUri.isAbsolute()) {
                    final URI base = (statusType.getStatusCode() == Status.CREATED.getStatusCode())
                            ? request.getAbsolutePath()
                            : request.getBaseUri();
                    location = UriBuilder.fromUri(base).
                            path(locationUri.getRawPath()).
                            replaceQuery(locationUri.getRawQuery()).
                            fragment(locationUri.getRawFragment()).
                            build();
                }
                headers.putSingle(HttpHeaders.LOCATION, location);
            }
        }
    }

}
