/*
 * Copyright (c) 2012-2016 Codenvy, S.A.
 * This program and the accompanying materials are made
 * available under the terms of the Eclipse Public License 2.0
 * which is available at https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *   Codenvy, S.A. - initial API and implementation
 */
package org.everrest.core.impl;

import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;

import org.everrest.core.ApplicationContext;
import org.everrest.core.GenericContainerRequest;
import org.everrest.core.impl.header.AcceptLanguage;
import org.everrest.core.impl.header.AcceptMediaType;
import org.everrest.core.impl.header.HeaderHelper;
import org.everrest.core.impl.header.MediaTypeHelper;

import javax.ws.rs.core.Cookie;
import javax.ws.rs.core.EntityTag;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.ResponseBuilder;
import javax.ws.rs.core.SecurityContext;
import javax.ws.rs.core.Variant;
import javax.ws.rs.ext.RuntimeDelegate;
import java.io.InputStream;
import java.net.URI;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Strings.isNullOrEmpty;
import static java.util.Collections.emptyList;
import static java.util.Collections.unmodifiableList;
import static java.util.Collections.unmodifiableMap;
import static java.util.stream.Collectors.toList;
import static javax.ws.rs.HttpMethod.GET;
import static javax.ws.rs.HttpMethod.HEAD;
import static javax.ws.rs.core.Response.Status.PRECONDITION_FAILED;
import static org.everrest.core.impl.header.HeaderHelper.convertToString;
import static org.everrest.core.impl.header.HeaderHelper.createAcceptMediaTypeList;
import static org.everrest.core.impl.header.HeaderHelper.createAcceptedLanguageList;

/**
 * @author andrew00x
 */
public class ContainerRequest implements GenericContainerRequest {
    /** HTTP method. */
    private String method;

    /** HTTP request message body as stream. */
    private InputStream entityStream;

    /** HTTP headers. */
    private MultivaluedMap<String, String> httpHeaders;

    /** Parsed HTTP cookies. */
    private Map<String, Cookie> cookies;

    /** Source strings of HTTP cookies. */
    private List<String> cookieHeaders;

    /** HTTP header Content-Type. */
    private MediaType contentType;

    /** HTTP header Content-Language. */
    private Locale contentLanguage;

    /** List of accepted media type, HTTP header Accept. List is sorted by quality value factor. */
    private List<AcceptMediaType> acceptableMediaTypes;

    /** List of accepted language, HTTP header Accept-Language. List is sorted by quality value factor. */
    private List<Locale> acceptLanguages;

    /** Full request URI, includes query string and fragment. */
    private URI requestUri;

    /** Base URI, e.g. servlet path. */
    private URI baseUri;

    /** Security context. */
    private SecurityContext securityContext;

    private VariantsHandler variantsHandler = new VariantsHandler();

    /**
     * Constructs new instance of ContainerRequest.
     *
     * @param method
     *         HTTP method
     * @param requestUri
     *         full request URI
     * @param baseUri
     *         base request URI
     * @param entityStream
     *         request message body as stream
     * @param httpHeaders
     *         HTTP headers
     * @param securityContext
     *         SecurityContext
     */
    public ContainerRequest(String method, URI requestUri, URI baseUri, InputStream entityStream,
                            MultivaluedMap<String, String> httpHeaders, SecurityContext securityContext) {
        this.method = method;
        this.requestUri = requestUri;
        this.baseUri = baseUri;
        this.entityStream = entityStream;
        this.httpHeaders = httpHeaders;
        this.securityContext = securityContext;
    }

    @Override
    public MediaType getAcceptableMediaType(List<MediaType> mediaTypes) {
        for (MediaType acceptMediaType : getAcceptableMediaTypes()) {
            for (MediaType checkMediaType : mediaTypes) {
                if (MediaTypeHelper.isMatched(acceptMediaType, checkMediaType)) {
                    return checkMediaType;
                }
            }
        }
        return null;
    }

    @Override
    public List<String> getCookieHeaders() {
        if (cookieHeaders == null) {
            List<String> cookieHeaders = getRequestHeader(COOKIE);
            if (cookieHeaders == null || cookieHeaders.isEmpty()) {
                this.cookieHeaders = emptyList();
            } else {
                this.cookieHeaders = unmodifiableList(cookieHeaders);
            }
        }
        return cookieHeaders;
    }

    @Override
    public InputStream getEntityStream() {
        return entityStream;
    }

    @Override
    public URI getRequestUri() {
        return requestUri;
    }

    @Override
    public URI getBaseUri() {
        return baseUri;
    }

    @Override
    public String getMethod() {
        return method;
    }

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

    @Override
    public void setEntityStream(InputStream entityStream) {
        this.entityStream = entityStream;

        ApplicationContext context = ApplicationContext.getCurrent();
        context.getAttributes().remove("org.everrest.provider.entity.decoded.form");
        context.getAttributes().remove("org.everrest.provider.entity.encoded.form");
    }

    @Override
    public void setUris(URI requestUri, URI baseUri) {
        this.requestUri = requestUri;
        this.baseUri = baseUri;
    }

    @Override
    public void setCookieHeaders(List<String> cookieHeaders) {
        this.cookieHeaders = cookieHeaders;
        this.cookies = null;
    }

    @Override
    public void setRequestHeaders(MultivaluedMap<String, String> httpHeaders) {
        this.httpHeaders = httpHeaders;

        this.cookieHeaders = null;
        this.cookies = null;
        this.contentType = null;
        this.contentLanguage = null;
        this.acceptableMediaTypes = null;
        this.acceptLanguages = null;
    }

    @Override
    public String getAuthenticationScheme() {
        return securityContext.getAuthenticationScheme();
    }

    @Override
    public Principal getUserPrincipal() {
        return securityContext.getUserPrincipal();
    }

    @Override
    public boolean isSecure() {
        return securityContext.isSecure();
    }

    @Override
    public boolean isUserInRole(String role) {
        return securityContext.isUserInRole(role);
    }

    @Override
    public ResponseBuilder evaluatePreconditions(EntityTag etag) {
        checkArgument(etag != null, "Null ETag is not supported");
        ResponseBuilder responseBuilder = evaluateIfMatch(etag);
        if (responseBuilder == null) {
            responseBuilder = evaluateIfNoneMatch(etag);
        }
        return responseBuilder;
    }

    @Override
    public ResponseBuilder evaluatePreconditions(Date lastModified) {
        checkArgument(lastModified != null, "Null last modification date is not supported");
        long lastModifiedTime = lastModified.getTime();
        ResponseBuilder responseBuilder = evaluateIfModified(lastModifiedTime);
        if (responseBuilder == null) {
            responseBuilder = evaluateIfUnmodified(lastModifiedTime);
        }
        return responseBuilder;
    }

    @Override
    public ResponseBuilder evaluatePreconditions(Date lastModified, EntityTag etag) {
        checkArgument(lastModified != null, "Null last modification date is not supported");
        checkArgument(etag != null, "Null ETag is not supported");

        ResponseBuilder responseBuilder = evaluateIfMatch(etag);
        if (responseBuilder != null) {
            return responseBuilder;
        }

        long lastModifiedTime = lastModified.getTime();
        responseBuilder = evaluateIfModified(lastModifiedTime);
        if (responseBuilder != null) {
            return responseBuilder;
        }

        responseBuilder = evaluateIfNoneMatch(etag);
        if (responseBuilder != null) {
            return responseBuilder;
        }

        return evaluateIfUnmodified(lastModifiedTime);
    }

    @Override
    public ResponseBuilder evaluatePreconditions() {
        List<String> ifMatch = getRequestHeader(IF_MATCH);
        return (ifMatch == null || ifMatch.isEmpty()) ? null : Response.status(PRECONDITION_FAILED);
    }


    @Override
    public Variant selectVariant(List<Variant> variants) {
        checkArgument(!(variants == null || variants.isEmpty()), "The list of variants is null or empty");
        return variantsHandler.handleVariants(this, variants);
    }

    /**
     * If accept-language header does not present or its length is null then default language list will be returned. This list contains
     * only one element Locale with language '*', and it minds any language accepted.
     */
    @Override
    public List<Locale> getAcceptableLanguages() {
        if (acceptLanguages == null) {
            List<AcceptLanguage> acceptLanguages = createAcceptedLanguageList(convertToString(getRequestHeader(ACCEPT_LANGUAGE)));
            List<Locale> locales = new ArrayList<>(acceptLanguages.size());
            locales.addAll(acceptLanguages.stream().map(language -> language.getLanguage().getLocale()).collect(toList()));

            this.acceptLanguages = unmodifiableList(locales);
        }

        return acceptLanguages;
    }

    @Override
    public List<MediaType> getAcceptableMediaTypes() {
        return getAcceptMediaTypeList().stream().map(AcceptMediaType::getMediaType).collect(toList());
    }

    @Override
    public List<AcceptMediaType> getAcceptMediaTypeList() {
        if (acceptableMediaTypes == null) {
            acceptableMediaTypes = createAcceptMediaTypeList(convertToString(getRequestHeader(ACCEPT)));
        }
        return acceptableMediaTypes;
    }

    @Override
    public Map<String, Cookie> getCookies() {
        if (this.cookies == null) {
            Map<String, Cookie> cookies = new HashMap<>();

            for (String cookieHeader : getCookieHeaders()) {
                List<Cookie> parsedCookies = HeaderHelper.parseCookies(cookieHeader);
                for (Cookie cookie : parsedCookies) {
                    cookies.put(cookie.getName(), cookie);
                }
            }

            this.cookies = unmodifiableMap(cookies);
        }

        return cookies;
    }

    @Override
    public Date getDate() {
        String date = getRequestHeaders().getFirst(DATE);
        return date == null ? null : HeaderHelper.parseDateHeader(date);
    }

    @Override
    public int getLength() {
        String length = getRequestHeaders().getFirst(CONTENT_LENGTH);
        return length == null ? -1 : Integer.parseInt(length);
    }

    @Override
    public Locale getLanguage() {
        if (contentLanguage == null && httpHeaders.getFirst(CONTENT_LANGUAGE) != null) {
            contentLanguage = RuntimeDelegate.getInstance().createHeaderDelegate(Locale.class).fromString(httpHeaders.getFirst(CONTENT_LANGUAGE));
        }

        return contentLanguage;
    }

    @Override
    public MediaType getMediaType() {
        if (contentType == null && httpHeaders.getFirst(CONTENT_TYPE) != null) {
            contentType = MediaType.valueOf(httpHeaders.getFirst(CONTENT_TYPE));
        }

        return contentType;
    }

    @Override
    public List<String> getRequestHeader(String name) {
        return httpHeaders.get(name);
    }

    @Override
    public String getHeaderString(String name) {
        return convertToString(getRequestHeader(name));
    }

    @Override
    public MultivaluedMap<String, String> getRequestHeaders() {
        return httpHeaders;
    }

    /**
     * Comparison for If-Match header and ETag.
     *
     * @param etag
     *         the ETag
     * @return ResponseBuilder with status 412 (precondition failed) if If-Match header does NOT MATCH to ETag or null otherwise
     */
    private ResponseBuilder evaluateIfMatch(EntityTag etag) {
        String ifMatch = getRequestHeaders().getFirst(IF_MATCH);

        if (isNullOrEmpty(ifMatch)) {
            return null;
        }

        EntityTag otherEtag = EntityTag.valueOf(ifMatch);

        if (eTagsStrongEqual(etag, otherEtag)) {
            return null;
        }
        return Response.status(PRECONDITION_FAILED);
    }

    /**
     * Comparison for If-None-Match header and ETag.
     *
     * @param etag
     *         the ETag
     * @return ResponseBuilder with status 412 (precondition failed) if If-None-Match header is MATCH to ETag and HTTP method is not GET or
     * HEAD. If method is GET or HEAD and If-None-Match is MATCH to ETag then ResponseBuilder with status 304 (not modified) will be
     * returned.
     */
    private ResponseBuilder evaluateIfNoneMatch(EntityTag etag) {
        String ifNoneMatch = getRequestHeaders().getFirst(IF_NONE_MATCH);

        if (Strings.isNullOrEmpty(ifNoneMatch)) {
            return null;
        }

        EntityTag otherEtag = EntityTag.valueOf(ifNoneMatch);
        String httpMethod = getMethod();
        if (httpMethod.equals(GET) || httpMethod.equals(HEAD)) {
            if (eTagsWeakEqual(etag, otherEtag)) {
                return Response.notModified(etag);
            }
        } else {
            if (eTagsStrongEqual(etag, otherEtag)) {
                return Response.status(PRECONDITION_FAILED);
            }
        }
        return null;
    }

    private boolean eTagsStrongEqual(EntityTag etag, EntityTag otherEtag) {
        // Strong comparison is required.
        // From specification:
        // The strong comparison function: in order to be considered equal,
        // both validators MUST be identical in every way, and both MUST NOT be weak.
        return !etag.isWeak() && !otherEtag.isWeak()
               && ("*".equals(otherEtag.getValue()) || etag.getValue().equals(otherEtag.getValue()));
    }

    private boolean eTagsWeakEqual(EntityTag etag, EntityTag otherEtag) {
        return "*".equals(otherEtag.getValue()) || etag.getValue().equals(otherEtag.getValue());
    }

    /**
     * Comparison for lastModified and unmodifiedSince times.
     *
     * @param lastModified
     *         the last modified time
     * @return ResponseBuilder with status 412 (precondition failed) if lastModified time is greater then unmodifiedSince otherwise return
     * null. If date format in header If-Unmodified-Since is wrong also null returned
     */
    private ResponseBuilder evaluateIfUnmodified(long lastModified) {
        String ifUnmodified = getRequestHeaders().getFirst(IF_UNMODIFIED_SINCE);

        if (isNullOrEmpty(ifUnmodified)) {
            return null;
        }
        try {
            long unmodifiedSince = HeaderHelper.parseDateHeader(ifUnmodified).getTime();
            if (lastModified > unmodifiedSince) {
                return Response.status(PRECONDITION_FAILED);
            }
        } catch (IllegalArgumentException ignored) {
        }

        return null;
    }

    /**
     * Comparison for lastModified and modifiedSince times.
     *
     * @param lastModified
     *         the last modified time
     * @return ResponseBuilder with status 304 (not modified) if lastModified time is greater then modifiedSince otherwise return null. If
     * date format in header If-Modified-Since is wrong also null returned
     */
    private ResponseBuilder evaluateIfModified(long lastModified) {
        String ifModified = getRequestHeaders().getFirst(IF_MODIFIED_SINCE);

        if (isNullOrEmpty(ifModified)) {
            return null;
        }
        try {
            long modifiedSince = HeaderHelper.parseDateHeader(ifModified).getTime();
            if (lastModified <= modifiedSince) {
                return Response.notModified();
            }
        } catch (IllegalArgumentException ignored) {
        }

        return null;
    }

    void setVariantsHandler(VariantsHandler variantsHandler) {
        this.variantsHandler = variantsHandler;
    }

    @Override
    public String toString() {
        return MoreObjects.toStringHelper(this)
                          .add("Method", method)
                          .add("BaseUri", baseUri)
                          .add("RequestUri", requestUri)
                          .toString();
    }
}