package net.lightbody.bmp.filters;

import com.google.common.collect.ImmutableList;
import com.google.common.io.BaseEncoding;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.HttpContent;
import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpObject;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.LastHttpContent;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.codec.http.cookie.ClientCookieDecoder;
import io.netty.handler.codec.http.cookie.Cookie;
import io.netty.handler.codec.http.cookie.ServerCookieDecoder;
import net.lightbody.bmp.core.har.Har;
import net.lightbody.bmp.core.har.HarCookie;
import net.lightbody.bmp.core.har.HarEntry;
import net.lightbody.bmp.core.har.HarNameValuePair;
import net.lightbody.bmp.core.har.HarPostData;
import net.lightbody.bmp.core.har.HarPostDataParam;
import net.lightbody.bmp.core.har.HarRequest;
import net.lightbody.bmp.core.har.HarResponse;
import net.lightbody.bmp.exception.UnsupportedCharsetException;
import net.lightbody.bmp.filters.support.HttpConnectTiming;
import net.lightbody.bmp.filters.util.HarCaptureUtil;
import net.lightbody.bmp.proxy.CaptureType;
import net.lightbody.bmp.util.BrowserMobHttpUtil;
import org.littleshoot.proxy.impl.ProxyUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Calendar;
import java.util.Date;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class HarCaptureFilter extends HttpsAwareFiltersAdapter {
    private static final Logger log = LoggerFactory.getLogger(HarCaptureFilter.class);

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

    /**
     * The harEntry is created when this filter is constructed and is shared by both the clientToProxyRequest
     * and serverToProxyResponse methods. It is added to the HarLog when the request is received from the client.
     */
    private final HarEntry harEntry;

    /**
     * The requestCaptureFilter captures all request content, including headers, trailing headers, and content. The HarCaptureFilter
     * delegates to it when the clientToProxyRequest() callback is invoked. If this request does not need content capture, the
     * ClientRequestCaptureFilter filter will not be instantiated and will not capture content.
     */
    private final ClientRequestCaptureFilter requestCaptureFilter;

    /**
     * Like requestCaptureFilter above, HarCaptureFilter delegates to responseCaptureFilter to capture response contents. If content capture
     * is not required for this request, the filter will not be instantiated or invoked.
     */
    private final ServerResponseCaptureFilter responseCaptureFilter;

    /**
     * The CaptureType data types to capture in this request.
     */
    private final EnumSet<CaptureType> dataToCapture;

    /**
     * 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.
     */
    private volatile long dnsResolutionStartedNanos;

    private volatile long connectionQueuedNanos;
    private volatile long connectionStartedNanos;

    private volatile long sendStartedNanos;
    private volatile long sendFinishedNanos;

    private volatile long responseReceiveStartedNanos;

    /**
     * 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;

    /**
     * Request body size is determined by the actual size of the data the client sends. The filter does not use the Content-Length header to determine request size.
     */
    private final AtomicInteger requestBodySize = new AtomicInteger(0);

    /**
     * Response body size is determined by the actual size of the data the server sends.
     */
    private final AtomicInteger responseBodySize = new AtomicInteger(0);

    /**
     * The "real" original request, as captured by the {@link #clientToProxyRequest(io.netty.handler.codec.http.HttpObject)} method.
     */
    private volatile HttpRequest capturedOriginalRequest;

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

    /**
     * Create a new instance of the HarCaptureFilter that will capture request and response information. If no har is specified in the
     * constructor, this filter will do nothing.
     * <p/>
     * Regardless of the CaptureTypes specified in <code>dataToCapture</code>, the HarCaptureFilter will always capture:
     * <ul>
     *     <li>Request and response sizes</li>
     *     <li>HTTP request and status lines</li>
     *     <li>Page timing information</li>
     * </ul>
     *
     * @param originalRequest the original HttpRequest from the HttpFiltersSource factory
     * @param har a reference to the ProxyServer's current HAR file at the time this request is received (can be null if HAR capture is not required)
     * @param currentPageRef the ProxyServer's currentPageRef at the time this request is received from the client
     * @param dataToCapture the data types to capture for this request. null or empty set indicates only basic information will be
     *                      captured (see {@link net.lightbody.bmp.proxy.CaptureType} for information on data collected for each CaptureType)
     */
    public HarCaptureFilter(HttpRequest originalRequest, ChannelHandlerContext ctx, Har har, String currentPageRef, Set<CaptureType> dataToCapture) {
        super(originalRequest, ctx);

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

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

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

        if (dataToCapture != null && !dataToCapture.isEmpty()) {
            this.dataToCapture = EnumSet.copyOf(dataToCapture);
        } else {
            this.dataToCapture = EnumSet.noneOf(CaptureType.class);
        }

        // we may need to capture both the request and the response, so set up the request/response filters and delegate to them when
        // the corresponding filter methods are invoked. to save time and memory, only set up the capturing filters when
        // we actually need to capture the data.
        if (this.dataToCapture.contains(CaptureType.REQUEST_CONTENT) || this.dataToCapture.contains(CaptureType.REQUEST_BINARY_CONTENT)) {
            requestCaptureFilter = new ClientRequestCaptureFilter(originalRequest);
        } else {
            requestCaptureFilter = null;
        }

        if (this.dataToCapture.contains(CaptureType.RESPONSE_CONTENT) || this.dataToCapture.contains(CaptureType.RESPONSE_BINARY_CONTENT)) {
            responseCaptureFilter = new ServerResponseCaptureFilter(originalRequest, true);
        } else {
            responseCaptureFilter = null;
        }

        this.har = har;

        this.harEntry = new HarEntry(currentPageRef);
    }

    @Override
    public HttpResponse clientToProxyRequest(HttpObject httpObject) {
        // if a ServerResponseCaptureFilter is configured, delegate to it to collect the client request. if it is not
        // configured, we still need to capture basic information (timings, possibly client headers, etc.), just not content.
        if (requestCaptureFilter != null) {
            requestCaptureFilter.clientToProxyRequest(httpObject);
        }

        if (httpObject instanceof HttpRequest) {
            // link the object up now, before we make the request, so that if we get cut off (ie: favicon.ico request and browser shuts down)
            // we still have the attempt associated, even if we never got a response
            harEntry.setStartedDateTime(new Date());
            har.getLog().addEntry(harEntry);

            HttpRequest httpRequest = (HttpRequest) httpObject;
            this.capturedOriginalRequest = httpRequest;

            // associate this request's HarRequest object with the har entry
            HarRequest request = createHarRequestForHttpRequest(httpRequest);
            harEntry.setRequest(request);

            // create a "no response received" HarResponse, in case the connection is interrupted, terminated, or the response is not received
            // for any other reason. having a "default" HarResponse prevents us from generating an invalid HAR.
            HarResponse defaultHarResponse = HarCaptureUtil.createHarResponseForFailure();
            defaultHarResponse.setError(HarCaptureUtil.getNoResponseReceivedErrorMessage());
            harEntry.setResponse(defaultHarResponse);

            captureQueryParameters(httpRequest);
            // not capturing user agent: in many cases, it doesn't make sense to capture at the HarLog level, since the proxy could be
            // serving requests from many different clients with various user agents. clients can turn on the REQUEST_HEADERS capture type
            // in order to capture the User-Agent header, if desired.
            captureRequestHeaderSize(httpRequest);

            if (dataToCapture.contains(CaptureType.REQUEST_COOKIES)) {
                captureRequestCookies(httpRequest);
            }

            if (dataToCapture.contains(CaptureType.REQUEST_HEADERS)) {
                captureRequestHeaders(httpRequest);
            }

            // The HTTP CONNECT to the proxy server establishes the SSL connection to the remote server, but the
            // HTTP CONNECT is not recorded in a separate HarEntry (except in case of error). Instead, the ssl and
            // connect times are recorded in the first request between the client and remote server after the HTTP CONNECT.
            captureConnectTiming();
        }

        if (httpObject instanceof HttpContent) {
            HttpContent httpContent = (HttpContent) httpObject;

            captureRequestSize(httpContent);
        }

        if (httpObject instanceof LastHttpContent) {
            LastHttpContent lastHttpContent = (LastHttpContent) httpObject;
            if (dataToCapture.contains(CaptureType.REQUEST_HEADERS)) {
                captureTrailingHeaders(lastHttpContent);
            }

            if (dataToCapture.contains(CaptureType.REQUEST_CONTENT)) {
                captureRequestContent(requestCaptureFilter.getHttpRequest(), requestCaptureFilter.getFullRequestContents());
            }

            harEntry.getRequest().setBodySize(requestBodySize.get());
        }

        return null;
    }

    @Override
    public HttpObject serverToProxyResponse(HttpObject httpObject) {
        // if a ServerResponseCaptureFilter is configured, delegate to it to collect the server's response. if it is not
        // configured, we still need to capture basic information (timings, HTTP status, etc.), just not content.
        if (responseCaptureFilter != null) {
            responseCaptureFilter.serverToProxyResponse(httpObject);
        }

        if (httpObject instanceof HttpResponse) {
            HttpResponse httpResponse = (HttpResponse) httpObject;

            captureResponse(httpResponse);
        }

        if (httpObject instanceof HttpContent) {
            HttpContent httpContent = (HttpContent) httpObject;

            captureResponseSize(httpContent);
        }

        if (httpObject instanceof LastHttpContent) {
            if (dataToCapture.contains(CaptureType.RESPONSE_CONTENT)) {
                captureResponseContent(responseCaptureFilter.getHttpResponse(), responseCaptureFilter.getFullResponseContents());
            }

            harEntry.getResponse().setBodySize(responseBodySize.get());
        }

        return super.serverToProxyResponse(httpObject);
    }

    @Override
    public void serverToProxyResponseTimedOut() {
        // replace any existing HarResponse that was created if the server sent a partial response
        HarResponse response = HarCaptureUtil.createHarResponseForFailure();
        harEntry.setResponse(response);

        response.setError(HarCaptureUtil.getResponseTimedOutErrorMessage());


        // 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);
        }
    }

    /**
     * Creates a HarRequest object using the method, url, and HTTP version of the specified request.
     *
     * @param httpRequest HTTP request on which the HarRequest will be based
     * @return a new HarRequest object
     */
    private HarRequest createHarRequestForHttpRequest(HttpRequest httpRequest) {
        // the HAR spec defines the request.url field as:
        //     url [string] - Absolute URL of the request (fragments are not included).
        // the URI on the httpRequest may only identify the path of the resource, so find the full URL.
        // the full URL consists of the scheme + host + port (if non-standard) + path + query params + fragment.
        String url = getFullUrl(httpRequest);

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

    //TODO: add unit tests for these utility-like capture() methods

    protected void captureQueryParameters(HttpRequest httpRequest) {
        // capture query parameters. it is safe to assume the query string is UTF-8, since it "should" be in US-ASCII (a subset of UTF-8),
        // but sometimes does include UTF-8 characters.
        QueryStringDecoder queryStringDecoder = new QueryStringDecoder(httpRequest.getUri(), StandardCharsets.UTF_8);

        try {
            for (Map.Entry<String, List<String>> entry : queryStringDecoder.parameters().entrySet()) {
                for (String value : entry.getValue()) {
                    harEntry.getRequest().getQueryString().add(new HarNameValuePair(entry.getKey(), value));
                }
            }
        } catch (IllegalArgumentException e) {
            // QueryStringDecoder will throw an IllegalArgumentException if it cannot interpret a query string. rather than cause the entire request to
            // fail by propagating the exception, simply skip the query parameter capture.
            harEntry.setComment("Unable to decode query parameters on URI: " + httpRequest.getUri());
            log.info("Unable to decode query parameters on URI: " + httpRequest.getUri(), e);
        }
    }

    protected void captureRequestHeaderSize(HttpRequest httpRequest) {
        String requestLine = httpRequest.getMethod().toString() + ' ' + httpRequest.getUri() + ' ' + httpRequest.getProtocolVersion().toString();
        // +2 => CRLF after status line, +4 => header/data separation
        long requestHeadersSize = requestLine.length() + 6;

        HttpHeaders headers = httpRequest.headers();
        requestHeadersSize += BrowserMobHttpUtil.getHeaderSize(headers);

        harEntry.getRequest().setHeadersSize(requestHeadersSize);
    }

    protected void captureRequestCookies(HttpRequest httpRequest) {
        String cookieHeader = httpRequest.headers().get(HttpHeaders.Names.COOKIE);
        if (cookieHeader == null) {
            return;
        }

        Set<Cookie> cookies = ServerCookieDecoder.LAX.decode(cookieHeader);

        for (Cookie cookie : cookies) {
            HarCookie harCookie = new HarCookie();

            harCookie.setName(cookie.name());
            harCookie.setValue(cookie.value());

            harEntry.getRequest().getCookies().add(harCookie);
        }
    }

    protected void captureRequestHeaders(HttpRequest httpRequest) {
        HttpHeaders headers = httpRequest.headers();

        captureHeaders(headers);
    }

    protected void captureTrailingHeaders(LastHttpContent lastHttpContent) {
        HttpHeaders headers = lastHttpContent.trailingHeaders();

        captureHeaders(headers);
    }

    protected void captureHeaders(HttpHeaders headers) {
        for (Map.Entry<String, String> header : headers.entries()) {
            harEntry.getRequest().getHeaders().add(new HarNameValuePair(header.getKey(), header.getValue()));
        }
    }

    protected void captureRequestContent(HttpRequest httpRequest, byte[] fullMessage) {
        if (fullMessage.length == 0) {
            return;
        }

        String contentType = HttpHeaders.getHeader(httpRequest, HttpHeaders.Names.CONTENT_TYPE);
        if (contentType == null) {
            log.warn("No content type specified in request to {}. Content will be treated as {}", httpRequest.getUri(), BrowserMobHttpUtil.UNKNOWN_CONTENT_TYPE);
            contentType = BrowserMobHttpUtil.UNKNOWN_CONTENT_TYPE;
        }

        HarPostData postData = new HarPostData();
        harEntry.getRequest().setPostData(postData);

        postData.setMimeType(contentType);

        boolean urlEncoded;
        if (contentType.startsWith(HttpHeaders.Values.APPLICATION_X_WWW_FORM_URLENCODED)) {
            urlEncoded = true;
        } else {
            urlEncoded = false;
        }

        Charset charset;
        try {
             charset = BrowserMobHttpUtil.readCharsetInContentTypeHeader(contentType);
        } catch (UnsupportedCharsetException e) {
            log.warn("Found unsupported character set in Content-Type header '{}' in HTTP request to {}. Content will not be captured in HAR.", contentType, httpRequest.getUri(), e);
            return;
        }

        if (charset == null) {
            // no charset specified, so use the default -- but log a message since this might not encode the data correctly
            charset = BrowserMobHttpUtil.DEFAULT_HTTP_CHARSET;
            log.debug("No charset specified; using charset {} to decode contents to {}", charset, httpRequest.getUri());
        }

        if (urlEncoded) {
            String textContents = BrowserMobHttpUtil.getContentAsString(fullMessage, charset);

            QueryStringDecoder queryStringDecoder = new QueryStringDecoder(textContents, charset, false);

            ImmutableList.Builder<HarPostDataParam> paramBuilder = ImmutableList.builder();

            for (Map.Entry<String, List<String>> entry : queryStringDecoder.parameters().entrySet()) {
                for (String value : entry.getValue()) {
                    paramBuilder.add(new HarPostDataParam(entry.getKey(), value));
                }
            }

            harEntry.getRequest().getPostData().setParams(paramBuilder.build());
        } else {
            //TODO: implement capture of files and multipart form data

            // not URL encoded, so let's grab the body of the POST and capture that
            String postBody = BrowserMobHttpUtil.getContentAsString(fullMessage, charset);
            harEntry.getRequest().getPostData().setText(postBody);
        }
    }

    protected void captureResponseContent(HttpResponse httpResponse, byte[] fullMessage) {
        // force binary if the content encoding is not supported
        boolean forceBinary = false;

        String contentType = HttpHeaders.getHeader(httpResponse, HttpHeaders.Names.CONTENT_TYPE);
        if (contentType == null) {
            log.warn("No content type specified in response from {}. Content will be treated as {}", originalRequest.getUri(), BrowserMobHttpUtil.UNKNOWN_CONTENT_TYPE);
            contentType = BrowserMobHttpUtil.UNKNOWN_CONTENT_TYPE;
        }

        if (responseCaptureFilter.isResponseCompressed() && !responseCaptureFilter.isDecompressionSuccessful()) {
            log.warn("Unable to decompress content with encoding: {}. Contents will be encoded as base64 binary data.", responseCaptureFilter.getContentEncoding());

            forceBinary = true;
        }

        Charset charset;
        try {
            charset = BrowserMobHttpUtil.readCharsetInContentTypeHeader(contentType);
        } catch (UnsupportedCharsetException e) {
            log.warn("Found unsupported character set in Content-Type header '{}' in HTTP response from {}. Content will not be captured in HAR.", contentType, originalRequest.getUri(), e);
            return;
        }

        if (charset == null) {
            // no charset specified, so use the default -- but log a message since this might not encode the data correctly
            charset = BrowserMobHttpUtil.DEFAULT_HTTP_CHARSET;
            log.debug("No charset specified; using charset {} to decode contents from {}", charset, originalRequest.getUri());
        }

        if (!forceBinary && BrowserMobHttpUtil.hasTextualContent(contentType)) {
            String text = BrowserMobHttpUtil.getContentAsString(fullMessage, charset);
            harEntry.getResponse().getContent().setText(text);
        } else if (dataToCapture.contains(CaptureType.RESPONSE_BINARY_CONTENT)) {
            harEntry.getResponse().getContent().setText(BaseEncoding.base64().encode(fullMessage));
            harEntry.getResponse().getContent().setEncoding("base64");
        }

        harEntry.getResponse().getContent().setSize(fullMessage.length);
    }

    protected void captureResponse(HttpResponse httpResponse) {
        HarResponse response = new HarResponse(httpResponse.getStatus().code(), httpResponse.getStatus().reasonPhrase(), httpResponse.getProtocolVersion().text());
        harEntry.setResponse(response);

        captureResponseHeaderSize(httpResponse);

        captureResponseMimeType(httpResponse);

        if (dataToCapture.contains(CaptureType.RESPONSE_COOKIES)) {
            captureResponseCookies(httpResponse);
        }

        if (dataToCapture.contains(CaptureType.RESPONSE_HEADERS)) {
            captureResponseHeaders(httpResponse);
        }

        if (BrowserMobHttpUtil.isRedirect(httpResponse)) {
            captureRedirectUrl(httpResponse);
        }
    }

    protected void captureResponseMimeType(HttpResponse httpResponse) {
        String contentType = HttpHeaders.getHeader(httpResponse, HttpHeaders.Names.CONTENT_TYPE);
        // don't set the mimeType to null, since mimeType is a required field
        if (contentType != null) {
            harEntry.getResponse().getContent().setMimeType(contentType);
        }
    }

    protected void captureResponseCookies(HttpResponse httpResponse) {
        List<String> setCookieHeaders = httpResponse.headers().getAll(HttpHeaders.Names.SET_COOKIE);
        if (setCookieHeaders == null) {
            return;
        }

        for (String setCookieHeader : setCookieHeaders) {
            Cookie cookie = ClientCookieDecoder.LAX.decode(setCookieHeader);
            if (cookie == null) {
                return;
            }

            HarCookie harCookie = new HarCookie();

            harCookie.setName(cookie.name());
            harCookie.setValue(cookie.value());
            // comment is no longer supported in the netty ClientCookieDecoder
            harCookie.setDomain(cookie.domain());
            harCookie.setHttpOnly(cookie.isHttpOnly());
            harCookie.setPath(cookie.path());
            harCookie.setSecure(cookie.isSecure());
            if (cookie.maxAge() > 0) {
                // use a Calendar with the current timestamp + maxAge seconds. the locale of the calendar is irrelevant,
                // since we are dealing with timestamps.
                Calendar expires = Calendar.getInstance();
                // zero out the milliseconds, since maxAge is in seconds
                expires.set(Calendar.MILLISECOND, 0);
                // we can't use Calendar.add, since that only takes ints. TimeUnit.convert handles second->millisecond
                // overflow reasonably well by returning the result as Long.MAX_VALUE.
                expires.setTimeInMillis(expires.getTimeInMillis() + TimeUnit.MILLISECONDS.convert(cookie.maxAge(), TimeUnit.SECONDS));

                harCookie.setExpires(expires.getTime());
            }

            harEntry.getResponse().getCookies().add(harCookie);
        }
    }

    protected void captureResponseHeaderSize(HttpResponse httpResponse) {
        String statusLine = httpResponse.getProtocolVersion().toString() + ' ' + httpResponse.getStatus().toString();
        // +2 => CRLF after status line, +4 => header/data separation
        long responseHeadersSize = statusLine.length() + 6;
        HttpHeaders headers = httpResponse.headers();
        responseHeadersSize += BrowserMobHttpUtil.getHeaderSize(headers);

        harEntry.getResponse().setHeadersSize(responseHeadersSize);
    }

    protected void captureResponseHeaders(HttpResponse httpResponse) {
        HttpHeaders headers = httpResponse.headers();
        for (Map.Entry<String, String> header : headers.entries()) {
            harEntry.getResponse().getHeaders().add(new HarNameValuePair(header.getKey(), header.getValue()));
        }
    }

    protected void captureRedirectUrl(HttpResponse httpResponse) {
        String locationHeaderValue = HttpHeaders.getHeader(httpResponse, HttpHeaders.Names.LOCATION);
        if (locationHeaderValue != null) {
            harEntry.getResponse().setRedirectURL(locationHeaderValue);
        }
    }

    /**
     * Adds the size of this httpContent to the requestBodySize.
     *
     * @param httpContent HttpContent to size
     */
    protected void captureRequestSize(HttpContent httpContent) {
        ByteBuf bufferedContent = httpContent.content();
        int contentSize = bufferedContent.readableBytes();
        requestBodySize.addAndGet(contentSize);
    }

    /**
     * Adds the size of this httpContent to the responseBodySize.
     *
     * @param httpContent HttpContent to size
     */
    protected void captureResponseSize(HttpContent httpContent) {
        ByteBuf bufferedContent = httpContent.content();
        int contentSize = bufferedContent.readableBytes();
        responseBodySize.addAndGet(contentSize);
    }

    /**
     * Populates ssl and connect timing info in the HAR if an entry for this client and server exist in the cache.
     */
    protected void captureConnectTiming() {
        HttpConnectTiming httpConnectTiming = HttpConnectHarCaptureFilter.consumeConnectTimingForConnection(clientAddress);
        if (httpConnectTiming != null) {
            harEntry.getTimings().setSsl(httpConnectTiming.getSslHandshakeTimeNanos(), TimeUnit.NANOSECONDS);
            harEntry.getTimings().setConnect(httpConnectTiming.getConnectTimeNanos(), TimeUnit.NANOSECONDS);
            harEntry.getTimings().setBlocked(httpConnectTiming.getBlockedTimeNanos(), TimeUnit.NANOSECONDS);
            harEntry.getTimings().setDns(httpConnectTiming.getDnsTimeNanos(), TimeUnit.NANOSECONDS);
        }
    }

    /**
     * Populates the serverIpAddress field of the harEntry using the internal hostname->IP address cache.
     *
     * @param httpRequest HTTP request to take the hostname from
     */
    protected void populateAddressFromCache(HttpRequest httpRequest) {
        String serverHost = getHost(httpRequest);

        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: {}", httpRequest.getUri());
        }
    }

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

        // resolution started means the connection is no longer queued, so populate 'blocked' time
        if (connectionQueuedNanos > 0L) {
            harEntry.getTimings().setBlocked(dnsResolutionStartedNanos - connectionQueuedNanos, TimeUnit.NANOSECONDS);
        } else {
            harEntry.getTimings().setBlocked(0L, TimeUnit.NANOSECONDS);
        }

        return null;
    }

    @Override
    public void proxyToServerResolutionFailed(String hostAndPort) {
        HarResponse response = HarCaptureUtil.createHarResponseForFailure();
        harEntry.setResponse(response);

        response.setError(HarCaptureUtil.getResolutionFailedErrorMessage(hostAndPort));

        // 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);
        }
    }

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

        if (dnsResolutionStartedNanos > 0L) {
            harEntry.getTimings().setDns(dnsResolutionFinishedNanos - dnsResolutionStartedNanos, TimeUnit.NANOSECONDS);
        } else {
            harEntry.getTimings().setDns(0L, TimeUnit.NANOSECONDS);
        }

        // the address *should* always be resolved at this point
        InetAddress resolvedAddress = resolvedRemoteAddress.getAddress();
        if (resolvedAddress != null) {
            addressResolved = true;

            harEntry.setServerIPAddress(resolvedAddress.getHostAddress());
        }
    }

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

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

    @Override
    public void proxyToServerConnectionFailed() {
        HarResponse response = HarCaptureUtil.createHarResponseForFailure();
        harEntry.setResponse(response);

        response.setError(HarCaptureUtil.getConnectionFailedErrorMessage());

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

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

        // make sure the previous timestamp was captured, to avoid setting an absurd value in the har (see serverToProxyResponseReceiving())
        if (connectionStartedNanos > 0L) {
            harEntry.getTimings().setConnect(connectionSucceededTimeNanos - connectionStartedNanos, TimeUnit.NANOSECONDS);
        } else {
            harEntry.getTimings().setConnect(0L, TimeUnit.NANOSECONDS);
        }
    }

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

        // if the hostname was not resolved (and thus the IP address populated in the har) during this request, populate the IP address from the cache
        if (!addressResolved) {
            populateAddressFromCache(capturedOriginalRequest);
        }
    }

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

        // make sure the previous timestamp was captured, to avoid setting an absurd value in the har (see serverToProxyResponseReceiving())
        if (sendStartedNanos > 0L) {
            harEntry.getTimings().setSend(sendFinishedNanos - sendStartedNanos, TimeUnit.NANOSECONDS);
        } else {
            harEntry.getTimings().setSend(0L, TimeUnit.NANOSECONDS);
        }
    }

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

        // started to receive response, so populate the 'wait' time. if we started receiving a response from the server before we finished
        // sending (for example, the server replied with a 404 while we were uploading a large file), there was no wait time, so
        // make sure the wait is set to 0.
        if (sendFinishedNanos > 0L && sendFinishedNanos < responseReceiveStartedNanos) {
            harEntry.getTimings().setWait(responseReceiveStartedNanos - sendFinishedNanos, TimeUnit.NANOSECONDS);
        } else {
            harEntry.getTimings().setWait(0L, TimeUnit.NANOSECONDS);
        }
    }

    @Override
    public void serverToProxyResponseReceived() {
        long responseReceivedNanos = System.nanoTime();

        // like the wait time, the receive time requires that the serverToProxyResponseReceiving() method be called before this method is invoked.
        // typically that should happen, but it has been reported (https://github.com/lightbody/browsermob-proxy/issues/288) that it
        // sometimes does not. therefore, to be safe, make sure responseReceiveStartedNanos is populated before setting the receive time.
        if (responseReceiveStartedNanos > 0L) {
            harEntry.getTimings().setReceive(responseReceivedNanos - responseReceiveStartedNanos, TimeUnit.NANOSECONDS);
        } else {
            harEntry.getTimings().setReceive(0L, TimeUnit.NANOSECONDS);
        }
    }
}
