/*
 * Copyright 2009-2013 Roland Huss
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.jolokia.jvmagent.handler;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.security.auth.Subject;

import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpsExchange;
import org.jolokia.jvmagent.ParsedUri;
import org.jolokia.server.core.config.ConfigKey;
import org.jolokia.server.core.http.BackChannelHolder;
import org.jolokia.server.core.http.HttpRequestHandler;
import org.jolokia.server.core.request.BadRequestException;
import org.jolokia.server.core.request.EmptyResponseException;
import org.jolokia.server.core.service.api.JolokiaContext;
import org.jolokia.server.core.util.IoUtil;
import org.jolokia.server.core.util.MimeTypeUtil;
import org.jolokia.json.JSONStructure;
import org.jolokia.server.core.util.SubjectAccess;
import org.jolokia.server.core.util.SubjectAccessProvider;

/**
 * HttpHandler for handling a Jolokia request
 *
 * @author roland
 * @since Mar 3, 2010
 */
public class JolokiaHttpHandler implements HttpHandler {

    // The HttpRequestHandler
    private final HttpRequestHandler requestHandler;

    // Context of this request
    private final String contextPath;

    // Content type matching
    private final Pattern contentTypePattern = Pattern.compile(".*;\\s*charset=([^;,]+)\\s*.*");

    // Global context
    private final JolokiaContext jolokiaContext;

    // whether to allow reverse DNS lookup for checking the remote host
    private final boolean allowDnsReverseLookup;

    // Backchannel Thread Pool (TODO: Optimize that for backchannel handling)
    private final Executor backChannelThreadPool = Executors.newCachedThreadPool();

    private final SimpleDateFormat rfc1123Format;

    private final SubjectAccess subjectAccess;

    /**
     * Create a new HttpHandler for processing HTTP request
     *
     * @param pJolokiaContext jolokia context
     */
    public JolokiaHttpHandler(JolokiaContext pJolokiaContext) {
        jolokiaContext = pJolokiaContext;

        contextPath = jolokiaContext.getConfig(ConfigKey.AGENT_CONTEXT);

        requestHandler = new HttpRequestHandler(jolokiaContext);
        allowDnsReverseLookup = Boolean.parseBoolean(jolokiaContext.getConfig(ConfigKey.ALLOW_DNS_REVERSE_LOOKUP));

        rfc1123Format = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);

        subjectAccess = SubjectAccessProvider.getSubjectAccess();
    }

    /**
     * Handle an incoming HTTP request. If the handler is not yet started, an exception is thrown. This is the
     * {@code com.sun.net.httpserver} equivalent of {@code org.jolokia.server.core.http.AgentServlet#handle()}.
     *
     * @param pHttpExchange the request/response object
     * @throws IOException if something fails during handling
     * @throws IllegalStateException if the handler has not yet been started
     */
    public void handle(final HttpExchange pHttpExchange) throws IOException {
        try {
            checkAuthentication(pHttpExchange);

            Subject subject = (Subject) pHttpExchange.getAttribute(ConfigKey.JAAS_SUBJECT_REQUEST_ATTRIBUTE);
            if (subject != null)  {
                doHandleAs(subject, pHttpExchange);
            }  else {
                doHandle(pHttpExchange);
            }
        } catch (SecurityException exp) {
            // Can happen only if a subclass implements checkAuthentication which actually throws a SecurityException
            sendForbidden(pHttpExchange, exp);
        }
    }

    /**
     * Protocol based authentication checks called very early and before handling a request.
     * If the check fails a security exception must be thrown
     * <p>
     * The default implementation does nothing and should be overridden for a valid check.
     *
     * @param pHttpExchange exchange to check
     * @throws SecurityException if check fails.
     */
    protected void checkAuthentication(HttpExchange pHttpExchange) throws SecurityException { }

    /**
     * Handle the incoming request within a {@link java.security.PrivilegedAction} when a {@link Subject} was found.
     *
     * @param subject
     * @param pHttpExchange
     * @throws BadRequestException
     */
    private void doHandleAs(Subject subject, final HttpExchange pHttpExchange) throws IOException {
        try {
            subjectAccess.callAs(subject, () -> {
                doHandle(pHttpExchange);
                return null;
            });
        } catch (Exception e) {
            // Exception happens _only_ when the action has thrown an checked exception.
            // But we handle all java.lang.Throwables in the doHandle() method anyway.
            // SecurityException happens _only_ under a SecurityManager and it's being removed anyway.
            sendInternalServerError(pHttpExchange, new Exception(e.getMessage() == null ? e.getClass().getName() : e.getMessage()));
        }
    }

    /**
     * Handle the incoming request. All exceptions during request processing are caught and turned into a Jolokia
     * JSON error. The only exception thrown is when the handled exception (turned into JSON) can't be sent back.
     *
     * @param pExchange the request/response object
     * @throws IOException thrown only if there's an issue sending the response.
     */
    public void doHandle(HttpExchange pExchange) throws IOException {
        JSONStructure json = null;
        URI uri = pExchange.getRequestURI();
        ParsedUri parsedUri = new ParsedUri(uri, contextPath);

        try {
            // Set back channel - for notification handling
            prepareBackChannel(pExchange);

            // Check access policy
            String scheme = pExchange instanceof HttpsExchange ? "https" : "http";
            InetSocketAddress address = pExchange.getRemoteAddress();
            String remoteHost = allowDnsReverseLookup ? address.getHostName() : null;
            requestHandler.checkAccess(scheme, remoteHost, address.getAddress().getHostAddress(), extractOriginOrReferer(pExchange));

            // If a callback is given, check this is a valid javascript function name
            validateCallbackIfGiven(parsedUri);

            // Dispatch for the proper HTTP request method
            String method = pExchange.getRequestMethod();
            if ("GET".equalsIgnoreCase(method)) {
                setHeaders(pExchange);
                json = executeGetRequest(parsedUri);
            } else if ("POST".equalsIgnoreCase(method)) {
                setHeaders(pExchange);
                json = executePostRequest(parsedUri, pExchange);
            } else if ("OPTIONS".equalsIgnoreCase(method)) {
                performCorsPreflightCheck(pExchange);
            } else {
                throw new BadRequestException("HTTP Method " + method + " is not supported.");
            }
        } catch (BadRequestException exp) {
            String response = "400 (Bad Request)\n";
            if (exp.getMessage() != null) {
                response += "\n" + exp.getMessage() + "\n";
            }
            pExchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8");
            pExchange.sendResponseHeaders(400, response.length());
            OutputStream os = pExchange.getResponseBody();
            os.write(response.getBytes());
            os.close();
            return;
        } catch (EmptyResponseException exp) {
            // Don't close the connection, this is used for `Content-Type: text/event-stream` for notifications
            // which will be sent using org.jolokia.service.notif.sse.SseHeartBeat
            return;
        } catch (Throwable exp) {
            // handle exception not handled by org.jolokia.server.core.http.HttpRequestHandler()
            // so for example should not be JMX exceptions
            json = requestHandler.handleThrowable(exp);
        } finally {
            releaseBackChannel();
        }

        if (json == null) {
            sendInternalServerError(pExchange, new Exception("Internal Server Error (no JSON response)"));
        } else {
            sendResponse(pExchange, parsedUri, json);
        }
    }

    private void prepareBackChannel(HttpExchange pExchange) {
        BackChannelHolder.set(new HttpExchangeBackChannel(pExchange, backChannelThreadPool));
    }

    private void releaseBackChannel() {
        BackChannelHolder.remove();
    }

    private void validateCallbackIfGiven(ParsedUri pUri) {
        String callback = pUri.getParameter(ConfigKey.CALLBACK.getKeyValue());
        if (callback != null && !MimeTypeUtil.isValidCallback(callback)) {
            throw new IllegalArgumentException("Invalid callback name given, which must be a valid javascript function name");
        }
    }

    // ========================================================================

    // Used for checking origin or referer is an origin policy is enabled
    private String extractOriginOrReferer(HttpExchange pExchange) {
        Headers headers = pExchange.getRequestHeaders();
        String origin = headers.getFirst("Origin");
        if (origin == null) {
            origin = headers.getFirst("Referer");
        }
        return origin != null ? origin.replaceAll("[\\n\\r]*","") : null;
    }

    /**
     * Handle a GET request, where all the parameters come from the URI/headers
     *
     * @param parsedUri
     * @return
     * @throws EmptyResponseException
     * @throws BadRequestException
     */
    private JSONStructure executeGetRequest(ParsedUri parsedUri) throws EmptyResponseException, BadRequestException {
        return requestHandler.handleGetRequest(parsedUri.getUri().toString(), parsedUri.getPathInfo(), parsedUri.getParameterMap());
    }

    /**
     * Handle a POST request, where the incoming data should also be read from the HTTP request body
     *
     * @param pUri
     * @param pExchange
     * @return
     * @throws IOException
     * @throws EmptyResponseException
     * @throws BadRequestException
     */
    private JSONStructure executePostRequest(ParsedUri pUri, HttpExchange pExchange) throws IOException, EmptyResponseException, BadRequestException {
        String encoding = null;
        Headers headers = pExchange.getRequestHeaders();
        String cType = headers.getFirst("Content-Type");
        if (cType != null) {
            Matcher matcher = contentTypePattern.matcher(cType);
            if (matcher.matches()) {
                encoding = matcher.group(1);
            }
        }
        InputStream is = pExchange.getRequestBody();
        return requestHandler.handlePostRequest(pUri.toString(), is, encoding, pUri.getParameterMap());
    }

    /**
     * Handle OPTIONS request (CORS)
     *
     * @param pExchange
     */
    private void performCorsPreflightCheck(HttpExchange pExchange) {
        Headers requestHeaders = pExchange.getRequestHeaders();
        Map<String,String> respHeaders =
                requestHandler.handleCorsPreflightRequest(extractOriginOrReferer(pExchange),
                                                          requestHeaders.getFirst("Access-Control-Request-Headers"));
        Headers responseHeaders = pExchange.getResponseHeaders();
        for (Map.Entry<String,String> entry : respHeaders.entrySet()) {
            responseHeaders.set(entry.getKey(), entry.getValue());
        }
    }

    private void setHeaders(HttpExchange pExchange) {
        String origin = requestHandler.extractCorsOrigin(pExchange.getRequestHeaders().getFirst("Origin"));
        Headers headers = pExchange.getResponseHeaders();
        if (origin != null) {
            headers.set("Access-Control-Allow-Origin",origin);
            headers.set("Access-Control-Allow-Credentials","true");
        }

        // Avoid caching at all costs
        headers.set("Cache-Control", "no-cache");

        // Check for a date header and set it accordingly to the recommendations of
        // RFC-2616. See also {@link AgentServlet#setNoCacheHeaders()}
        // Issue: #71
        Calendar cal = Calendar.getInstance();
        headers.set("Date",formatHeaderDate(cal.getTime()));
        // 1h  in the past since it seems, that some servlet set the date header on their
        // own so that it cannot be guaranteed that these headers are really equals.
        // It happened on Tomcat that "Date:" was finally set *before* "Expires:" in the final
        // answers sometimes which seems to be an implementation peculiarity from Tomcat
        cal.add(Calendar.HOUR, -1);
        headers.set("Expires",formatHeaderDate(cal.getTime()));
    }

    /**
     * Should be used only under a SecurityManager or if this handler is reimplemented with special
     * {@link #checkAuthentication(HttpExchange)}
     *
     * @param pExchange
     * @param securityException
     * @throws IOException
     */
    private void sendForbidden(HttpExchange pExchange, SecurityException securityException) throws IOException {
        String response = "403 (Forbidden)\n";
        if (securityException != null && securityException.getMessage() != null) {
            response += "\n" + securityException.getMessage() + "\n";
        }
        pExchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8");
        pExchange.sendResponseHeaders(403, response.length());
        OutputStream os = pExchange.getResponseBody();
        os.write(response.getBytes());
        os.close();
    }

    /**
     * Called when we can't produce a Jolokia JSON response or error.
     *
     * @param pExchange
     * @param exception
     * @throws IOException
     */
    private void sendInternalServerError(HttpExchange pExchange, Exception exception) throws IOException {
        String response = "500 (Internal Server Error)\n";
        if (exception != null && exception.getMessage() != null) {
            response += "\n" + exception.getMessage() + "\n";
        }
        pExchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8");
        pExchange.sendResponseHeaders(500, response.length());
        OutputStream os = pExchange.getResponseBody();
        os.write(response.getBytes());
        os.close();
    }

    private void sendResponse(HttpExchange pExchange, ParsedUri pParsedUri, JSONStructure pJson) throws IOException {
        Headers headers = pExchange.getResponseHeaders();
        if (pJson != null) {
            headers.set("Content-Type", getMimeType(pParsedUri) + "; charset=utf-8");
            pExchange.sendResponseHeaders(200, 0);
            Writer writer = new OutputStreamWriter(pExchange.getResponseBody(), StandardCharsets.UTF_8);

            String callback = pParsedUri.getParameter(ConfigKey.CALLBACK.getKeyValue());
            IoUtil.streamResponseAndClose(writer, pJson, callback != null && MimeTypeUtil.isValidCallback(callback) ? callback : null);
        } else {
            headers.set("Content-Type", "text/plain");
            pExchange.sendResponseHeaders(200,-1);
        }
    }

    private void sendAllJSON(HttpExchange pExchange, ParsedUri pParsedUri, JSONStructure pJson) throws IOException {
        OutputStream out = null;
        try {
            Headers headers = pExchange.getResponseHeaders();
            if (pJson != null) {
                headers.set("Content-Type", getMimeType(pParsedUri) + "; charset=utf-8");
                String json = pJson.toJSONString();
                String callback = pParsedUri.getParameter(ConfigKey.CALLBACK.getKeyValue());
                String content = callback != null && MimeTypeUtil.isValidCallback(callback) ? callback + "(" + json + ");" : json;
                byte[] response = content.getBytes(StandardCharsets.UTF_8);
                pExchange.sendResponseHeaders(200,response.length);
                out = pExchange.getResponseBody();
                out.write(response);
            } else {
                headers.set("Content-Type", "text/plain");
                pExchange.sendResponseHeaders(200,-1);
            }
        } finally {
            if (out != null) {
                // Always close in order to finish the request.
                // Otherwise, the thread blocks.
                out.close();
            }
        }
    }

    // Get the proper mime type according to configuration
    private String getMimeType(ParsedUri pParsedUri) {
        return MimeTypeUtil.getResponseMimeType(
            pParsedUri.getParameter(ConfigKey.MIME_TYPE.getKeyValue()),
            jolokiaContext.getConfig(ConfigKey.MIME_TYPE),
            pParsedUri.getParameter(ConfigKey.CALLBACK.getKeyValue()));
    }


    private String formatHeaderDate(Date date) {
        rfc1123Format.setTimeZone(TimeZone.getTimeZone("GMT"));
        return rfc1123Format.format(date);
    }
}
