package net.lightbody.bmp.filters;

import com.google.common.cache.CacheBuilder;

import net.lightbody.bmp.core.har.Har;
import net.lightbody.bmp.core.har.HarEntry;
import net.lightbody.bmp.core.har.HarRequest;
import net.lightbody.bmp.core.har.HarResponse;
import net.lightbody.bmp.core.har.HarTimings;
import net.lightbody.bmp.filters.support.HttpConnectTiming;
import net.lightbody.bmp.filters.util.HarCaptureUtil;
import net.lightbody.bmp.util.HttpUtil;

import org.littleshoot.proxy.impl.ProxyUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.util.Date;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.TimeUnit;

import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.HttpObject;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;

/**
 * This filter captures HAR data for HTTP CONNECT requests. CONNECTs are "meta" requests that must be made before HTTPS
 * requests, but are not populated as separate requests in the HAR. Most information from HTTP CONNECTs (such as SSL
 * handshake time, dns resolution time, etc.) is populated in the HAR entry for the first "true" request following the
 * CONNECT. This filter captures the timing-related information and makes it available to subsequent filters through
 * static methods. This filter also handles HTTP CONNECT errors and creates HAR entries for those errors, since there
 * would otherwise not be any record in the HAR of the error (if the CONNECT fails, there will be no subsequent "real"
 * request in which to record the error).
 *
 */
public class HttpConnectHarCaptureFilter extends HttpsAwareFiltersAdapter implements ModifiedRequestAwareFilter {
    private static final Logger log = LoggerFactory.getLogger(HttpConnectHarCaptureFilter.class);

    /**
     * The currently active HAR at the time the current request is received.
     */
    private final Har har;

    /**
     * The currently active page ref at the time the current request is received.
     */
    private final String currentPageRef;

    /**
     * The time this CONNECT began. Used to populate the HAR entry in case of failure.
     */
    private volatile Date requestStartTime;

    /**
     * True if this filter instance processed a {@link #proxyToServerResolutionSucceeded(String, InetSocketAddress)} call, indicating
     * that the hostname was resolved and populated in the HAR (if this is not a CONNECT).
     */
//    private volatile boolean addressResolved = false;
    private volatile InetAddress resolvedAddress;

    /**
     * Populated by proxyToServerResolutionStarted when DNS resolution starts. If any previous filters already resolved the address, their resolution time
     * will not be included in this time. See {@link HarCaptureFilter#dnsResolutionStartedNanos}.
     */
    private volatile long dnsResolutionStartedNanos;

    private volatile long dnsResolutionFinishedNanos;

    private volatile long connectionQueuedNanos;
    private volatile long connectionStartedNanos;
    private volatile long connectionSucceededTimeNanos;
    private volatile long sendStartedNanos;
    private volatile long sendFinishedNanos;

    private volatile long responseReceiveStartedNanos;
    private volatile long sslHandshakeStartedNanos;

    /**
     * The address of the client making the request. Captured in the constructor and used when calculating and capturing ssl handshake and connect
     * timing information for SSL connections.
     */
    private final InetSocketAddress clientAddress;

    /**
     * Stores HTTP CONNECT timing information for this request, if it is an HTTP CONNECT.
     */
    private final HttpConnectTiming httpConnectTiming;

    /**
     * The maximum amount of time to save timing information between an HTTP CONNECT and the subsequent HTTP request. Typically this is done
     * immediately, but if for some reason it is not (e.g. due to a client crash or dropped connection), the timing information will be
     * kept for this long before being evicted to prevent a memory leak. If a subsequent request does come through after eviction, it will still
     * be recorded, but the timing information will not be populated in the HAR.
     */
    private static final int HTTP_CONNECT_TIMING_EVICTION_SECONDS = 60;

    /**
     * Concurrency of the httpConnectTiming map. Should be approximately equal to the maximum number of simultaneous connection
     * attempts (but not necessarily simultaneous connections). A lower value will inhibit performance.
     * TODO: tune this value for a large number of concurrent requests. develop a non-cache-based mechanism of passing ssl timings to subsequent requests.
     */
    private static final int HTTP_CONNECT_TIMING_CONCURRENCY_LEVEL = 50;

    /**
     * Stores SSL connection timing information from HTTP CONNNECT requests. This timing information is stored in the first HTTP request
     * after the CONNECT, not in the CONNECT itself, so it needs to be stored across requests.
     *
     * This is the only state stored across multiple requests.
     */
    private static final ConcurrentMap<InetSocketAddress, HttpConnectTiming> httpConnectTimes =
            CacheBuilder.newBuilder()
                    .expireAfterWrite(HTTP_CONNECT_TIMING_EVICTION_SECONDS, TimeUnit.SECONDS)
                    .concurrencyLevel(HTTP_CONNECT_TIMING_CONCURRENCY_LEVEL)
                    .<InetSocketAddress, HttpConnectTiming>build()
                    .asMap();

    private volatile HttpRequest modifiedHttpRequest;

    public HttpConnectHarCaptureFilter(HttpRequest originalRequest, ChannelHandlerContext ctx, Har har, String currentPageRef) {
        super(originalRequest, ctx);

        if (har == null) {
            throw new IllegalStateException("Attempted har capture when har is null");
        }

        if (!ProxyUtils.isCONNECT(originalRequest)) {
            throw new IllegalStateException("Attempted HTTP CONNECT har capture on non-HTTP CONNECT request");
        }

        this.har = har;
        this.currentPageRef = currentPageRef;

        this.clientAddress = (InetSocketAddress) ctx.channel().remoteAddress();

        // create and cache an HTTP CONNECT timing object to capture timing-related information
        this.httpConnectTiming = new HttpConnectTiming();
        httpConnectTimes.put(clientAddress, httpConnectTiming);
    }

    @Override
    public HttpResponse clientToProxyRequest(HttpObject httpObject) {
        if (httpObject instanceof HttpRequest) {
            // store the CONNECT start time in case of failure, so we can populate the HarEntry with it
            requestStartTime = new Date();
        }

        return null;
    }

    @Override
    public void proxyToServerResolutionFailed(String hostAndPort) {
        // since this is a CONNECT, which is not handled by the HarCaptureFilter, we need to create and populate the
        // entire HarEntry and add it to this har.
        HarEntry harEntry = createHarEntryForFailedCONNECT(HarCaptureUtil.getResolutionFailedErrorMessage(hostAndPort));
        har.getLog().addEntry(harEntry);

        // record the amount of time we attempted to resolve the hostname in the HarTimings object
        if (dnsResolutionStartedNanos > 0L) {
            harEntry.getTimings().setDns(System.nanoTime() - dnsResolutionStartedNanos, TimeUnit.NANOSECONDS);
        }

        httpConnectTimes.remove(clientAddress);
    }

    @Override
    public void proxyToServerConnectionFailed() {
        // since this is a CONNECT, which is not handled by the HarCaptureFilter, we need to create and populate the
        // entire HarEntry and add it to this har.
        HarEntry harEntry = createHarEntryForFailedCONNECT(HarCaptureUtil.getConnectionFailedErrorMessage());
        har.getLog().addEntry(harEntry);

        // record the amount of time we attempted to connect in the HarTimings object
        if (connectionStartedNanos > 0L) {
            harEntry.getTimings().setConnect(System.nanoTime() - connectionStartedNanos, TimeUnit.NANOSECONDS);
        }

        httpConnectTimes.remove(clientAddress);
    }

    @Override
    public void proxyToServerConnectionSucceeded(ChannelHandlerContext serverCtx) {
        this.connectionSucceededTimeNanos = System.nanoTime();

        if (connectionStartedNanos > 0L) {
            httpConnectTiming.setConnectTimeNanos(connectionSucceededTimeNanos - connectionStartedNanos);
        } else {
            httpConnectTiming.setConnectTimeNanos(0L);
        }

        if (sslHandshakeStartedNanos > 0L) {
            httpConnectTiming.setSslHandshakeTimeNanos(connectionSucceededTimeNanos - sslHandshakeStartedNanos);
        } else {
            httpConnectTiming.setSslHandshakeTimeNanos(0L);
        }
    }

    @Override
    public void proxyToServerConnectionSSLHandshakeStarted() {
        this.sslHandshakeStartedNanos = System.nanoTime();
    }

    @Override
    public void serverToProxyResponseTimedOut() {
        HarEntry harEntry = createHarEntryForFailedCONNECT(HarCaptureUtil.getResponseTimedOutErrorMessage());
        har.getLog().addEntry(harEntry);

        // include this timeout time in the HarTimings object
        long timeoutTimestampNanos = System.nanoTime();

        // if the proxy started to send the request but has not yet finished, we are currently "sending"
        if (sendStartedNanos > 0L && sendFinishedNanos == 0L) {
            harEntry.getTimings().setSend(timeoutTimestampNanos - sendStartedNanos, TimeUnit.NANOSECONDS);
        }
        // if the entire request was sent but the proxy has not begun receiving the response, we are currently "waiting"
        else if (sendFinishedNanos > 0L && responseReceiveStartedNanos == 0L) {
            harEntry.getTimings().setWait(timeoutTimestampNanos - sendFinishedNanos, TimeUnit.NANOSECONDS);
        }
        // if the proxy has already begun to receive the response, we are currenting "receiving"
        else if (responseReceiveStartedNanos > 0L) {
            harEntry.getTimings().setReceive(timeoutTimestampNanos - responseReceiveStartedNanos, TimeUnit.NANOSECONDS);
        }
    }

    @Override
    public void proxyToServerConnectionQueued() {
        this.connectionQueuedNanos = System.nanoTime();
    }


    @Override
    public InetSocketAddress proxyToServerResolutionStarted(String resolvingServerHostAndPort) {
        dnsResolutionStartedNanos = System.nanoTime();

        if (connectionQueuedNanos > 0L) {
            httpConnectTiming.setBlockedTimeNanos(dnsResolutionStartedNanos - connectionQueuedNanos);
        } else {
            httpConnectTiming.setBlockedTimeNanos(0L);
        }

        return null;
    }

    @Override
    public void proxyToServerResolutionSucceeded(String serverHostAndPort, InetSocketAddress resolvedRemoteAddress) {
        this.dnsResolutionFinishedNanos = System.nanoTime();

        if (dnsResolutionStartedNanos > 0L) {
            httpConnectTiming.setDnsTimeNanos(dnsResolutionFinishedNanos - dnsResolutionStartedNanos);
        } else {
            httpConnectTiming.setDnsTimeNanos(0L);
        }

        // the address *should* always be resolved at this point
        this.resolvedAddress = resolvedRemoteAddress.getAddress();
    }

    @Override
    public void proxyToServerConnectionStarted() {
        this.connectionStartedNanos = System.nanoTime();
    }

    @Override
    public void proxyToServerRequestSending() {
        this.sendStartedNanos = System.nanoTime();
    }

    @Override
    public void proxyToServerRequestSent() {
        this.sendFinishedNanos = System.nanoTime();
    }

    @Override
    public void serverToProxyResponseReceiving() {
        this.responseReceiveStartedNanos = System.nanoTime();
    }

    /**
     * Populates timing information in the specified harEntry for failed rquests. Populates as much timing information
     * as possible, up to the point of failure.
     *
     * @param harEntry HAR entry to populate timing information in
     */
    private void populateTimingsForFailedCONNECT(HarEntry harEntry) {
        HarTimings timings = harEntry.getTimings();

        if (connectionQueuedNanos > 0L && dnsResolutionStartedNanos > 0L) {
            timings.setBlocked(dnsResolutionStartedNanos - connectionQueuedNanos, TimeUnit.NANOSECONDS);
        }

        if (dnsResolutionStartedNanos > 0L && dnsResolutionFinishedNanos > 0L) {
            timings.setDns(dnsResolutionFinishedNanos - dnsResolutionStartedNanos, TimeUnit.NANOSECONDS);
        }

        if (connectionStartedNanos > 0L && connectionSucceededTimeNanos > 0L) {
            timings.setConnect(connectionSucceededTimeNanos - connectionStartedNanos, TimeUnit.NANOSECONDS);

            if (sslHandshakeStartedNanos > 0L) {
                timings.setSsl(connectionSucceededTimeNanos - this.sslHandshakeStartedNanos, TimeUnit.NANOSECONDS);
            }
        }

        if (sendStartedNanos > 0L && sendFinishedNanos >= 0L) {
            timings.setSend(sendFinishedNanos - sendStartedNanos, TimeUnit.NANOSECONDS);
        }

        if (sendFinishedNanos > 0L && responseReceiveStartedNanos >= 0L) {
            timings.setWait(responseReceiveStartedNanos - sendFinishedNanos, TimeUnit.NANOSECONDS);
        }

        // since this method is for HTTP CONNECT failures only, we can't populate a "received" time, since that would
        // require the CONNECT to be successful, in which case this method wouldn't be called.
    }

    /**
     * Creates a {@link HarEntry} for a failed CONNECT request. Initializes and populates the entry, including the
     * {@link HarRequest}, {@link HarResponse}, and {@link HarTimings}. (Note: only successful timing information is
     * populated in the timings object; the calling method must populate the timing information for the final, failed
     * step. For example, if DNS resolution failed, this method will populate the network 'blocked' time, but not the DNS
     * time.) Populates the specified errorMessage in the {@link HarResponse}'s error field.
     *
     * @param errorMessage error message to place in the har response
     * @return a new HAR entry
     */
    private HarEntry createHarEntryForFailedCONNECT(String errorMessage) {
        HarEntry harEntry = new HarEntry(currentPageRef);
        harEntry.setStartedDateTime(requestStartTime);

        HarRequest request = createRequestForFailedConnect(originalRequest);
        harEntry.setRequest(request);

        HarResponse response = HarCaptureUtil.createHarResponseForFailure();
        harEntry.setResponse(response);

        response.setError(errorMessage);

        populateTimingsForFailedCONNECT(harEntry);

        populateServerIpAddress(harEntry);


        return harEntry;
    }

    private void populateServerIpAddress(HarEntry harEntry) {
        // populate the server IP address if it was resolved as part of this request. otherwise, populate the IP address from the cache.
        if (resolvedAddress != null) {
            harEntry.setServerIPAddress(resolvedAddress.getHostAddress());
        } else {
            String serverHost = HttpUtil.getHostFromRequest(modifiedHttpRequest);
            if (serverHost != null && !serverHost.isEmpty()) {
                String resolvedAddress = ResolvedHostnameCacheFilter.getPreviouslyResolvedAddressForHost(serverHost);
                if (resolvedAddress != null) {
                    harEntry.setServerIPAddress(resolvedAddress);
                } else {
                    // the resolvedAddress may be null if the ResolvedHostnameCacheFilter has expired the entry (which is unlikely),
                    // or in the far more common case that the proxy is using a chained proxy to connect to connect to the
                    // remote host. since the chained proxy handles IP address resolution, the IP address in the HAR must be blank.
                    log.trace("Unable to find cached IP address for host: {}. IP address in HAR entry will be blank.", serverHost);
                }
            } else {
                log.warn("Unable to identify host from request uri: {}", modifiedHttpRequest.getUri());
            }
        }
    }

    /**
     * Creates a new {@link HarRequest} object for this failed HTTP CONNECT. Does not populate fields within the request,
     * such as the error message.
     *
     * @param httpConnectRequest the HTTP CONNECT request that failed
     * @return a new HAR request object
     */
    private HarRequest createRequestForFailedConnect(HttpRequest httpConnectRequest) {
        String url = getFullUrl(httpConnectRequest);

        return new HarRequest(httpConnectRequest.getMethod().toString(), url, httpConnectRequest.getProtocolVersion().text());
    }

    /**
     * Retrieves and removes (thus "consumes") the SSL timing information from the connection cache for the specified address.
     *
     * @param clientAddress the address of the client connection that established the HTTP tunnel
     * @return the timing information for the tunnel previously established from the clientAddress
     */
    public static HttpConnectTiming consumeConnectTimingForConnection(InetSocketAddress clientAddress) {
        return httpConnectTimes.remove(clientAddress);
    }

    @Override
    public void setModifiedHttpRequest(HttpRequest modifiedHttpRequest) {
        this.modifiedHttpRequest = modifiedHttpRequest;
    }
}