/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.camel.component.olingo4.api.impl;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Consumer;

import org.apache.camel.component.olingo4.api.Olingo4App;
import org.apache.camel.component.olingo4.api.Olingo4ResponseHandler;
import org.apache.camel.component.olingo4.api.batch.Olingo4BatchChangeRequest;
import org.apache.camel.component.olingo4.api.batch.Olingo4BatchQueryRequest;
import org.apache.camel.component.olingo4.api.batch.Olingo4BatchRequest;
import org.apache.camel.component.olingo4.api.batch.Olingo4BatchResponse;
import org.apache.camel.component.olingo4.api.batch.Operation;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.ObjectHelper;
import org.apache.commons.io.IOUtils;
import org.apache.commons.io.LineIterator;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.Consts;
import org.apache.http.Header;
import org.apache.http.HttpException;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.HttpResponseFactory;
import org.apache.http.HttpVersion;
import org.apache.http.StatusLine;
import org.apache.http.client.entity.DecompressingEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.concurrent.FutureCallback;
import org.apache.http.config.MessageConstraints;
import org.apache.http.entity.AbstractHttpEntity;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.DefaultHttpResponseFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.DefaultHttpResponseParser;
import org.apache.http.impl.io.HttpTransportMetricsImpl;
import org.apache.http.impl.io.SessionInputBufferImpl;
import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
import org.apache.http.impl.nio.client.HttpAsyncClients;
import org.apache.http.message.BasicLineParser;
import org.apache.olingo.client.api.ODataBatchConstants;
import org.apache.olingo.client.api.ODataClient;
import org.apache.olingo.client.api.communication.request.ODataStreamer;
import org.apache.olingo.client.api.communication.request.batch.ODataBatchLineIterator;
import org.apache.olingo.client.api.domain.ClientEntity;
import org.apache.olingo.client.api.domain.ClientPrimitiveValue;
import org.apache.olingo.client.api.domain.ClientProperty;
import org.apache.olingo.client.api.serialization.ODataReader;
import org.apache.olingo.client.api.serialization.ODataWriter;
import org.apache.olingo.client.api.uri.SegmentType;
import org.apache.olingo.client.core.ODataClientFactory;
import org.apache.olingo.client.core.communication.request.batch.ODataBatchController;
import org.apache.olingo.client.core.communication.request.batch.ODataBatchLineIteratorImpl;
import org.apache.olingo.client.core.communication.request.batch.ODataBatchUtilities;
import org.apache.olingo.client.core.http.HttpMerge;
import org.apache.olingo.commons.api.Constants;
import org.apache.olingo.commons.api.edm.Edm;
import org.apache.olingo.commons.api.edm.EdmPrimitiveTypeKind;
import org.apache.olingo.commons.api.edm.EdmReturnType;
import org.apache.olingo.commons.api.edm.constants.ODataServiceVersion;
import org.apache.olingo.commons.api.ex.ODataException;
import org.apache.olingo.commons.api.format.ContentType;
import org.apache.olingo.commons.api.http.HttpHeader;
import org.apache.olingo.commons.api.http.HttpStatusCode;
import org.apache.olingo.server.api.OData;
import org.apache.olingo.server.api.uri.UriInfo;
import org.apache.olingo.server.api.uri.UriInfoKind;
import org.apache.olingo.server.api.uri.UriParameter;
import org.apache.olingo.server.api.uri.UriResource;
import org.apache.olingo.server.api.uri.UriResourceEntitySet;
import org.apache.olingo.server.api.uri.UriResourceFunction;
import org.apache.olingo.server.api.uri.UriResourceKind;
import org.apache.olingo.server.core.uri.parser.Parser;

import static org.apache.camel.component.olingo4.api.impl.Olingo4Helper.getContentTypeHeader;

/**
 * Application API used by Olingo4 Component.
 */
public final class Olingo4AppImpl implements Olingo4App {

    private static final String SEPARATOR = "/";
    private static final String BOUNDARY_PREFIX = "batch_";
    private static final String BOUNDARY_PARAMETER = "; boundary=";
    private static final String BOUNDARY_DOUBLE_DASH = "--";
    private static final String MULTIPART_MIME_TYPE = "multipart/";
    private static final String CONTENT_ID_HEADER = "Content-ID";
    private static final String CLIENT_ENTITY_FAKE_MARKER = "('X')";

    private static final ContentType DEFAULT_CONTENT_TYPE = ContentType.create(ContentType.APPLICATION_JSON, ContentType.PARAMETER_CHARSET, Constants.UTF8);
    private static final ContentType METADATA_CONTENT_TYPE = ContentType.create(ContentType.APPLICATION_XML, ContentType.PARAMETER_CHARSET, Constants.UTF8);
    private static final ContentType TEXT_PLAIN_WITH_CS_UTF_8 = ContentType.create(ContentType.TEXT_PLAIN, ContentType.PARAMETER_CHARSET, Constants.UTF8);
    private static final ContentType SERVICE_DOCUMENT_CONTENT_TYPE = ContentType.APPLICATION_JSON;
    private static final ContentType BATCH_CONTENT_TYPE = ContentType.MULTIPART_MIXED;

    /**
     * Reference to CloseableHttpAsyncClient (default) or CloseableHttpClient
     */
    private final Closeable client;

    /**
     * Reference to ODataClient reader and writer
     */
    private final ODataClient odataClient = ODataClientFactory.getClient();
    private final ODataReader odataReader = odataClient.getReader();
    private final ODataWriter odataWriter = odataClient.getWriter();

    private String serviceUri;

    private ContentType contentType;
    private Map<String, String> httpHeaders;

    /**
     * Create Olingo4 Application with default HTTP configuration.
     */
    public Olingo4AppImpl(String serviceUri) {
        // By default create HTTP asynchronous client
        this(serviceUri, (HttpClientBuilder)null);
    }

    /**
     * Create Olingo4 Application with custom HTTP Asynchronous client builder.
     *
     * @param serviceUri Service Application base URI.
     * @param builder custom HTTP client builder.
     */
    public Olingo4AppImpl(String serviceUri, HttpAsyncClientBuilder builder) {
        setServiceUri(serviceUri);

        CloseableHttpAsyncClient asyncClient;
        if (builder == null) {
            asyncClient = HttpAsyncClients.createDefault();
        } else {
            asyncClient = builder.build();
        }
        asyncClient.start();
        this.client = asyncClient;
        this.contentType = DEFAULT_CONTENT_TYPE;
    }

    /**
     * Create Olingo4 Application with custom HTTP Synchronous client builder.
     *
     * @param serviceUri Service Application base URI.
     * @param builder Custom HTTP Synchronous client builder.
     */
    public Olingo4AppImpl(String serviceUri, HttpClientBuilder builder) {
        setServiceUri(serviceUri);

        if (builder == null) {
            this.client = HttpClients.createDefault();
        } else {
            this.client = builder.build();
        }
        this.contentType = DEFAULT_CONTENT_TYPE;
    }

    @Override
    public void setServiceUri(String serviceUri) {
        if (serviceUri == null || serviceUri.isEmpty()) {
            throw new IllegalArgumentException("serviceUri is not set");
        }
        this.serviceUri = serviceUri.endsWith(SEPARATOR) ? serviceUri.substring(0, serviceUri.length() - 1) : serviceUri;
    }

    @Override
    public String getServiceUri() {
        return serviceUri;
    }

    @Override
    public Map<String, String> getHttpHeaders() {
        return httpHeaders;
    }

    @Override
    public void setHttpHeaders(Map<String, String> httpHeaders) {
        this.httpHeaders = httpHeaders;
    }

    @Override
    public String getContentType() {
        return contentType.toContentTypeString();
    }

    @Override
    public void setContentType(String contentType) {
        this.contentType = ContentType.parse(contentType);
    }

    @Override
    public void close() {
        IOHelper.close(client);
    }

    @Override
    public <T> void read(final Edm edm, final String resourcePath, final Map<String, String> queryParams, final Map<String, String> endpointHttpHeaders,
                         final Olingo4ResponseHandler<T> responseHandler) {
        final String queryOptions = concatQueryParams(queryParams);
        final UriInfo uriInfo = parseUri(edm, resourcePath, queryOptions, serviceUri);

        execute(new HttpGet(createUri(resourcePath, queryOptions)), getResourceContentType(uriInfo), endpointHttpHeaders, new AbstractFutureCallback<T>(responseHandler) {

            @Override
            public void onCompleted(HttpResponse result) throws IOException {
                readContent(uriInfo, result.getEntity() != null ? result.getEntity().getContent() : null, headersToMap(result.getAllHeaders()), responseHandler);
            }

        });
    }

    @Override
    public void uread(final Edm edm, final String resourcePath, final Map<String, String> queryParams, final Map<String, String> endpointHttpHeaders,
                      final Olingo4ResponseHandler<InputStream> responseHandler) {
        final String queryOptions = concatQueryParams(queryParams);
        final UriInfo uriInfo = parseUri(edm, resourcePath, queryOptions, serviceUri);

        execute(new HttpGet(createUri(resourcePath, queryOptions)), getResourceContentType(uriInfo), endpointHttpHeaders, new AbstractFutureCallback<InputStream>(responseHandler) {

            @Override
            public void onCompleted(HttpResponse result) throws IOException {
                InputStream responseStream = result.getEntity() != null ? result.getEntity().getContent() : null;
                if (responseStream != null && result.getEntity() instanceof DecompressingEntity) {
                    // In case of GZIP compression it's necessary to create
                    // InputStream from the source byte array
                    responseHandler.onResponse(new ByteArrayInputStream(IOUtils.toByteArray(responseStream)), headersToMap(result.getAllHeaders()));
                } else {
                    responseHandler.onResponse(responseStream, headersToMap(result.getAllHeaders()));
                }
            }
        });
    }

    @Override
    public <T> void create(final Edm edm, final String resourcePath, final Map<String, String> endpointHttpHeaders, final Object data,
                           final Olingo4ResponseHandler<T> responseHandler) {
        final UriInfo uriInfo = parseUri(edm, resourcePath, null, serviceUri);

        writeContent(edm, new HttpPost(createUri(resourcePath, null)), uriInfo, data, endpointHttpHeaders, responseHandler);
    }

    @Override
    public <T> void update(final Edm edm, final String resourcePath, final Map<String, String> endpointHttpHeaders, final Object data,
                           final Olingo4ResponseHandler<T> responseHandler) {
        final UriInfo uriInfo = parseUri(edm, resourcePath, null, serviceUri);

        augmentWithETag(edm, resourcePath, endpointHttpHeaders, new HttpPut(createUri(resourcePath, null)),
        request -> writeContent(edm, (HttpPut)request, uriInfo, data, endpointHttpHeaders, responseHandler), responseHandler);
    }

    @Override
    public void delete(final String resourcePath, final Map<String, String> endpointHttpHeaders, final Olingo4ResponseHandler<HttpStatusCode> responseHandler) {
        HttpDelete deleteRequest = new HttpDelete(createUri(resourcePath));

        Consumer<HttpRequestBase> deleteFunction = request -> {
            execute(request, contentType, endpointHttpHeaders, new AbstractFutureCallback<HttpStatusCode>(responseHandler) {
                @Override
                public void onCompleted(HttpResponse result) {
                    final StatusLine statusLine = result.getStatusLine();
                    responseHandler.onResponse(HttpStatusCode.fromStatusCode(statusLine.getStatusCode()), headersToMap(result.getAllHeaders()));
                }
            });
        };

        augmentWithETag(null, resourcePath, endpointHttpHeaders, deleteRequest, deleteFunction, responseHandler);
    }

    @Override
    public <T> void patch(final Edm edm, final String resourcePath, final Map<String, String> endpointHttpHeaders, final Object data,
                          final Olingo4ResponseHandler<T> responseHandler) {
        final UriInfo uriInfo = parseUri(edm, resourcePath, null, serviceUri);

        augmentWithETag(edm, resourcePath, endpointHttpHeaders, new HttpPatch(createUri(resourcePath, null)),
        request -> writeContent(edm, (HttpPatch)request, uriInfo, data, endpointHttpHeaders, responseHandler), responseHandler);
    }

    @Override
    public <T> void merge(final Edm edm, final String resourcePath, final Map<String, String> endpointHttpHeaders, final Object data,
                          final Olingo4ResponseHandler<T> responseHandler) {
        final UriInfo uriInfo = parseUri(edm, resourcePath, null, serviceUri);

        augmentWithETag(edm, resourcePath, endpointHttpHeaders, new HttpMerge(createUri(resourcePath, null)),
        request -> writeContent(edm, (HttpMerge)request, uriInfo, data, endpointHttpHeaders, responseHandler), responseHandler);
    }

    @Override
    public void batch(final Edm edm, final Map<String, String> endpointHttpHeaders, final Object data, final Olingo4ResponseHandler<List<Olingo4BatchResponse>> responseHandler) {
        final UriInfo uriInfo = parseUri(edm, SegmentType.BATCH.getValue(), null, serviceUri);

        writeContent(edm, new HttpPost(createUri(SegmentType.BATCH.getValue(), null)), uriInfo, data, endpointHttpHeaders, responseHandler);
    }

    @Override
    public <T> void action(final Edm edm, final String resourcePath, final Map<String, String> endpointHttpHeaders, final Object data,
                           final Olingo4ResponseHandler<T> responseHandler) {
        final UriInfo uriInfo = parseUri(edm, resourcePath, null, serviceUri);

        writeContent(edm, new HttpPost(createUri(resourcePath, null)), uriInfo, data, endpointHttpHeaders, responseHandler);
    }

    private ContentType getResourceContentType(UriInfo uriInfo) {
        ContentType resourceContentType;
        switch (uriInfo.getKind()) {
        case service:
            // service document
            resourceContentType = SERVICE_DOCUMENT_CONTENT_TYPE;
            break;
        case metadata:
            // metadata
            resourceContentType = METADATA_CONTENT_TYPE;
            break;
        case resource:
            List<UriResource> listResource = uriInfo.getUriResourceParts();
            UriResourceKind lastResourceKind = listResource.get(listResource.size() - 1).getKind();
            // is it a $value or $count URI??
            if (lastResourceKind == UriResourceKind.count || lastResourceKind == UriResourceKind.value) {
                resourceContentType = TEXT_PLAIN_WITH_CS_UTF_8;
            } else {
                resourceContentType = contentType;
            }
            break;
        default:
            resourceContentType = contentType;
        }
        return resourceContentType;
    }

    /**
     * On occasion, some resources are protected with Optimistic Concurrency via
     * the use of eTags. This will first conduct a read on the given entity
     * resource, find its eTag then perform the given delegate request function,
     * augmenting the request with the eTag, if appropriate. Since read
     * operations may be asynchronous, it is necessary to chain together the
     * methods via the use of a {@link Consumer} function. Only when the
     * response from the read returns will this delegate function be executed.
     *
     * @param edm the Edm object to be interrogated
     * @param resourcePath the resource path of the entity to be operated on
     * @param endpointHttpHeaders the headers provided from the endpoint which
     *            may be required for the read operation
     * @param httpRequest the request to be updated, if appropriate, with the
     *            eTag and provided to the delegate request function
     * @param delegateRequestFn the function to be invoked in response to the
     *            read operation
     * @param delegateResponseHandler the response handler to respond if any
     *            errors occur during the read operation
     */
    private <T> void augmentWithETag(final Edm edm, final String resourcePath, final Map<String, String> endpointHttpHeaders, final HttpRequestBase httpRequest,
                                     final Consumer<HttpRequestBase> delegateRequestFn, final Olingo4ResponseHandler<T> delegateResponseHandler) {

        if (edm == null) {
            // Can be the case if calling a delete then need to do a metadata
            // call first
            final Olingo4ResponseHandler<Edm> edmResponseHandler = new Olingo4ResponseHandler<Edm>() {
                @Override
                public void onResponse(Edm response, Map<String, String> responseHeaders) {
                    //
                    // Call this method again with an intact edm object
                    //
                    augmentWithETag(response, resourcePath, endpointHttpHeaders, httpRequest, delegateRequestFn, delegateResponseHandler);
                }

                @Override
                public void onException(Exception ex) {
                    delegateResponseHandler.onException(ex);
                }

                @Override
                public void onCanceled() {
                    delegateResponseHandler.onCanceled();
                }
            };

            //
            // Reads the metadata to establish an Edm object
            // then the response handler invokes this method again with the new
            // edm object
            //
            read(null, Constants.METADATA, null, null, edmResponseHandler);

        } else {

            //
            // The handler that responds to the read operation and supplies an
            // ETag if necessary
            // and invokes the delegate request function
            //
            Olingo4ResponseHandler<T> eTagReadHandler = new Olingo4ResponseHandler<T>() {

                @Override
                public void onResponse(T response, Map<String, String> responseHeaders) {
                    if (response instanceof ClientEntity) {
                        ClientEntity e = (ClientEntity)response;
                        Optional.ofNullable(e.getETag()).ifPresent(v -> httpRequest.addHeader("If-Match", v));
                    }

                    // Invoke the delegate request function providing the
                    // modified request
                    delegateRequestFn.accept(httpRequest);
                }

                @Override
                public void onException(Exception ex) {
                    delegateResponseHandler.onException(ex);
                }

                @Override
                public void onCanceled() {
                    delegateResponseHandler.onCanceled();
                }
            };

            read(edm, resourcePath, null, endpointHttpHeaders, eTagReadHandler);
        }
    }

    private <T> void readContent(UriInfo uriInfo, InputStream content, Map<String, String> endpointHttpHeaders, Olingo4ResponseHandler<T> responseHandler) {
        try {
            responseHandler.onResponse(this.<T> readContent(uriInfo, content), endpointHttpHeaders);
        } catch (Exception e) {
            responseHandler.onException(e);
        } catch (Error e) {
            responseHandler.onException(new ODataException("Runtime Error Occurred", e));
        }
    }

    @SuppressWarnings("unchecked")
    private <T> T readContent(UriInfo uriInfo, InputStream content) throws ODataException {
        T response = null;
        switch (uriInfo.getKind()) {
        case service:
            // service document
            response = (T)odataReader.readServiceDocument(content, SERVICE_DOCUMENT_CONTENT_TYPE);
            break;

        case metadata:
            // $metadata
            response = (T)odataReader.readMetadata(content);
            break;
        case resource:
            // any resource entity
            List<UriResource> listResource = uriInfo.getUriResourceParts();
            UriResourceKind lastResourceKind = listResource.get(listResource.size() - 1).getKind();
            switch (lastResourceKind) {
            case entitySet:
                UriResourceEntitySet uriResourceEntitySet = (UriResourceEntitySet)listResource.get(listResource.size() - 1);
                List<UriParameter> keyPredicates = uriResourceEntitySet.getKeyPredicates();
                // Check result type: single Entity or EntitySet based
                // on key predicate detection
                if (keyPredicates.size() == 1) {
                    response = (T)odataReader.readEntity(content, getResourceContentType(uriInfo));
                } else {
                    response = (T)odataReader.readEntitySet(content, getResourceContentType(uriInfo));
                }
                break;
            case count:
                String stringCount = null;
                try {
                    stringCount = IOUtils.toString(content, Consts.UTF_8);
                    response = (T)Long.valueOf(stringCount);
                } catch (IOException e) {
                    throw new ODataException("Error during $count value deserialization", e);
                } catch (NumberFormatException e) {
                    throw new ODataException("Error during $count value conversion: " + stringCount, e);
                }
                break;
            case value:
                try {
                    ClientPrimitiveValue value = odataClient.getObjectFactory().newPrimitiveValueBuilder().setType(EdmPrimitiveTypeKind.String)
                        .setValue(IOUtils.toString(content, Consts.UTF_8)).build();
                    response = (T)value;
                } catch (IOException e) {
                    throw new ODataException("Error during $value deserialization", e);
                }
                break;
            case primitiveProperty:
            case complexProperty:
                ClientProperty property = odataReader.readProperty(content, getResourceContentType(uriInfo));
                if (property.hasPrimitiveValue()) {
                    response = (T)property.getPrimitiveValue();
                } else if (property.hasComplexValue()) {
                    response = (T)property.getComplexValue();
                } else if (property.hasCollectionValue()) {
                    response = (T)property.getCollectionValue();
                } else {
                    throw new ODataException("Unsupported property: " + property.getName());
                }
                break;
            case function:
                UriResourceFunction uriResourceFunction = (UriResourceFunction)listResource.get(listResource.size() - 1);
                EdmReturnType functionReturnType = uriResourceFunction.getFunction().getReturnType();

                switch (functionReturnType.getType().getKind()) {
                case ENTITY:
                    if (functionReturnType.isCollection()) {
                        response = (T)odataReader.readEntitySet(content, getResourceContentType(uriInfo));
                    } else {
                        response = (T)odataReader.readEntity(content, getResourceContentType(uriInfo));
                    }
                    break;
                case PRIMITIVE:
                case COMPLEX:
                    ClientProperty functionProperty = odataReader.readProperty(content, getResourceContentType(uriInfo));
                    if (functionProperty.hasPrimitiveValue()) {
                        response = (T)functionProperty.getPrimitiveValue();
                    } else if (functionProperty.hasComplexValue()) {
                        response = (T)functionProperty.getComplexValue();
                    } else if (functionProperty.hasCollectionValue()) {
                        response = (T)functionProperty.getCollectionValue();
                    } else {
                        throw new ODataException("Unsupported property: " + functionProperty.getName());
                    }
                    break;
                default:
                    throw new ODataException("Unsupported function return type " + uriInfo.getKind().name());
                }
                break;
            default:
                throw new ODataException("Unsupported resource type: " + lastResourceKind.name());
            }
            break;

        default:
            throw new ODataException("Unsupported resource type " + uriInfo.getKind().name());
        }

        return response;
    }

    private <T> void writeContent(final Edm edm, HttpEntityEnclosingRequestBase httpEntityRequest, final UriInfo uriInfo, final Object content,
                                  final Map<String, String> endpointHttpHeaders, final Olingo4ResponseHandler<T> responseHandler) {

        try {
            httpEntityRequest.setEntity(writeContent(edm, uriInfo, content));

            final Header requestContentTypeHeader = httpEntityRequest.getEntity().getContentType();
            final ContentType requestContentType = requestContentTypeHeader != null ? ContentType.parse(requestContentTypeHeader.getValue()) : contentType;

            execute(httpEntityRequest, requestContentType, endpointHttpHeaders, new AbstractFutureCallback<T>(responseHandler) {
                @SuppressWarnings("unchecked")
                @Override
                public void onCompleted(HttpResponse result) throws IOException, ODataException {

                    // if a entity is created (via POST request) the response
                    // body contains the new created entity
                    HttpStatusCode statusCode = HttpStatusCode.fromStatusCode(result.getStatusLine().getStatusCode());

                    // look for no content, or no response body!!!
                    final boolean noEntity = result.getEntity() == null || result.getEntity().getContentLength() == 0;
                    if (statusCode == HttpStatusCode.NO_CONTENT || noEntity) {
                        responseHandler.onResponse((T)HttpStatusCode.fromStatusCode(result.getStatusLine().getStatusCode()), headersToMap(result.getAllHeaders()));
                    } else {
                        if (uriInfo.getKind() == UriInfoKind.resource) {
                            List<UriResource> listResource = uriInfo.getUriResourceParts();
                            UriResourceKind lastResourceKind = listResource.get(listResource.size() - 1).getKind();
                            switch (lastResourceKind) {
                            case action:
                            case entitySet:
                                ClientEntity entity = odataReader.readEntity(result.getEntity().getContent(), ContentType.parse(result.getEntity().getContentType().getValue()));
                                responseHandler.onResponse((T)entity, headersToMap(result.getAllHeaders()));
                                break;
                            default:
                                break;
                            }
                        } else if (uriInfo.getKind() == UriInfoKind.batch) {
                            List<Olingo4BatchResponse> batchResponse = parseBatchResponse(edm, result, (List<Olingo4BatchRequest>)content);
                            responseHandler.onResponse((T)batchResponse, headersToMap(result.getAllHeaders()));
                        } else {
                            throw new ODataException("Unsupported resource type: " + uriInfo.getKind().name());
                        }
                    }
                }
            });

        } catch (Exception e) {
            responseHandler.onException(e);
        } catch (Error e) {
            responseHandler.onException(new ODataException("Runtime Error Occurred", e));
        }
    }

    private AbstractHttpEntity writeContent(final Edm edm, final UriInfo uriInfo, final Object content) throws ODataException {
        AbstractHttpEntity httpEntity;

        if (uriInfo.getKind() == UriInfoKind.resource) {
            // any resource entity
            List<UriResource> listResource = uriInfo.getUriResourceParts();
            UriResourceKind lastResourceKind = listResource.get(listResource.size() - 1).getKind();
            switch (lastResourceKind) {
            case action:
                if (content == null) { // actions may have no input
                    httpEntity = new ByteArrayEntity(new byte[0]);
                } else {
                    httpEntity = writeContent(uriInfo, content);
                }
                break;
            case entitySet:
                httpEntity = writeContent(uriInfo, content);
                break;
            default:
                throw new ODataException("Unsupported resource type: " + lastResourceKind);
            }
        } else if (uriInfo.getKind() == UriInfoKind.batch) {
            final String boundary = BOUNDARY_PREFIX + UUID.randomUUID();
            final String contentHeader = BATCH_CONTENT_TYPE + BOUNDARY_PARAMETER + boundary;
            final List<Olingo4BatchRequest> batchParts = (List<Olingo4BatchRequest>)content;

            final InputStream requestStream = serializeBatchRequest(edm, batchParts, BOUNDARY_DOUBLE_DASH + boundary);
            httpEntity = writeContent(requestStream);
            httpEntity.setContentType(contentHeader);
        } else {
            throw new ODataException("Unsupported resource type: " + uriInfo.getKind().name());
        }

        return httpEntity;
    }

    private AbstractHttpEntity writeContent(UriInfo uriInfo, Object content) throws ODataException {
        AbstractHttpEntity httpEntity;

        if (content instanceof ClientEntity) {
            final InputStream requestStream = odataWriter.writeEntity((ClientEntity)content, getResourceContentType(uriInfo));
            httpEntity = writeContent(requestStream);
        } else if (content instanceof String) {
            httpEntity = new StringEntity((String)content, org.apache.http.entity.ContentType.APPLICATION_JSON);
        } else {
            throw new ODataException("Unsupported content type: " + content);
        }

        httpEntity.setChunked(false);

        return httpEntity;
    }

    private AbstractHttpEntity writeContent(InputStream inputStream) throws ODataException {
        AbstractHttpEntity httpEntity;

        try {
            httpEntity = new ByteArrayEntity(IOUtils.toByteArray(inputStream));
        } catch (IOException e) {
            throw new ODataException("Error during converting input stream to byte array", e);
        }

        httpEntity.setChunked(false);

        return httpEntity;
    }

    private InputStream serializeBatchRequest(final Edm edm, final List<Olingo4BatchRequest> batchParts, String boundary) throws ODataException {
        final ByteArrayOutputStream batchRequestHeaderOutputStream = new ByteArrayOutputStream();

        try {
            batchRequestHeaderOutputStream.write(boundary.getBytes(Constants.UTF8));
            batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);

            for (Olingo4BatchRequest batchPart : batchParts) {
                writeHttpHeader(batchRequestHeaderOutputStream, HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_HTTP.toContentTypeString());
                writeHttpHeader(batchRequestHeaderOutputStream, ODataBatchConstants.ITEM_TRANSFER_ENCODING_LINE, null);

                if (batchPart instanceof Olingo4BatchQueryRequest) {
                    final Olingo4BatchQueryRequest batchQueryPart = (Olingo4BatchQueryRequest)batchPart;
                    final String batchQueryUri = createUri(StringUtils.isBlank(batchQueryPart.getResourceUri()) ? serviceUri : batchQueryPart.getResourceUri(),
                                                           batchQueryPart.getResourcePath(), concatQueryParams(batchQueryPart.getQueryParams()));
                    final UriInfo uriInfo = parseUri(edm, batchQueryPart.getResourcePath(), concatQueryParams(batchQueryPart.getQueryParams()), serviceUri);
                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);

                    batchRequestHeaderOutputStream.write((HttpGet.METHOD_NAME + " " + batchQueryUri + " " + HttpVersion.HTTP_1_1).getBytes(Constants.UTF8));
                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                    final ContentType acceptType = getResourceContentType(uriInfo);
                    final String acceptCharset = acceptType.getParameter(ContentType.PARAMETER_CHARSET);
                    writeHttpHeader(batchRequestHeaderOutputStream, HttpHeaders.ACCEPT, contentType.getType().toLowerCase() + "/" + contentType.getSubtype().toLowerCase());
                    if (null != acceptCharset) {
                        writeHttpHeader(batchRequestHeaderOutputStream, HttpHeaders.ACCEPT_CHARSET, acceptCharset.toLowerCase());
                    }

                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                    batchRequestHeaderOutputStream.write(boundary.getBytes(Constants.UTF8));
                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                } else if (batchPart instanceof Olingo4BatchChangeRequest) {
                    final Olingo4BatchChangeRequest batchChangePart = (Olingo4BatchChangeRequest)batchPart;
                    final String batchChangeUri = createUri(StringUtils.isBlank(batchChangePart.getResourceUri()) ? serviceUri : batchChangePart.getResourceUri(),
                                                            batchChangePart.getResourcePath(), null);
                    final UriInfo uriInfo = parseUri(edm, batchChangePart.getResourcePath(), null, serviceUri);

                    if (batchChangePart.getOperation() != Operation.DELETE) {
                        writeHttpHeader(batchRequestHeaderOutputStream, CONTENT_ID_HEADER, batchChangePart.getContentId());
                    }

                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                    batchRequestHeaderOutputStream
                        .write((batchChangePart.getOperation().getHttpMethod() + " " + batchChangeUri + " " + HttpVersion.HTTP_1_1).getBytes(Constants.UTF8));
                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                    writeHttpHeader(batchRequestHeaderOutputStream, HttpHeader.ODATA_VERSION, ODataServiceVersion.V40.toString());
                    final ContentType acceptType = getResourceContentType(uriInfo);
                    final String acceptCharset = acceptType.getParameter(ContentType.PARAMETER_CHARSET);
                    writeHttpHeader(batchRequestHeaderOutputStream, HttpHeaders.ACCEPT, contentType.getType().toLowerCase() + "/" + contentType.getSubtype().toLowerCase());
                    if (null != acceptCharset) {
                        writeHttpHeader(batchRequestHeaderOutputStream, HttpHeaders.ACCEPT_CHARSET, acceptCharset.toLowerCase());
                    }
                    writeHttpHeader(batchRequestHeaderOutputStream, HttpHeaders.CONTENT_TYPE, acceptType.toContentTypeString());

                    if (batchChangePart.getOperation() != Operation.DELETE) {
                        batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                        AbstractHttpEntity httpEnity = writeContent(edm, uriInfo, batchChangePart.getBody());

                        batchRequestHeaderOutputStream.write(IOUtils.toByteArray(httpEnity.getContent()));
                        batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                        batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                    } else {
                        batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                    }

                    batchRequestHeaderOutputStream.write(boundary.getBytes(Constants.UTF8));
                    batchRequestHeaderOutputStream.write(ODataStreamer.CRLF);
                } else {
                    throw new ODataException("Unsupported batch part request object type: " + batchPart);
                }
            }
        } catch (Exception e) {
            throw new ODataException("Error during batch request serialization", e);
        }
        return new ByteArrayInputStream(batchRequestHeaderOutputStream.toByteArray());
    }

    private void writeHttpHeader(ByteArrayOutputStream headerOutputStream, String headerName, String headerValue) throws IOException {
        headerOutputStream.write(createHttpHeader(headerName, headerValue).getBytes(Constants.UTF8));
        headerOutputStream.write(ODataStreamer.CRLF);
    }

    private String createHttpHeader(String headerName, String headerValue) {
        return headerName + (StringUtils.isBlank(headerValue) ? "" : (": " + headerValue));
    }

    private List<Olingo4BatchResponse> parseBatchResponse(final Edm edm, HttpResponse response, List<Olingo4BatchRequest> batchRequest) throws ODataException {
        List<Olingo4BatchResponse> batchResponse = new <Olingo4BatchResponse> ArrayList();
        try {
            final Header[] contentHeaders = response.getHeaders(HttpHeader.CONTENT_TYPE);
            final ODataBatchLineIterator batchLineIterator = new ODataBatchLineIteratorImpl(IOUtils.lineIterator(response.getEntity().getContent(), Constants.UTF8));
            final String batchBoundary = ODataBatchUtilities.getBoundaryFromHeader(getHeadersCollection(contentHeaders));
            final ODataBatchController batchController = new ODataBatchController(batchLineIterator, batchBoundary);

            batchController.getBatchLineIterator().next();
            int batchRequestIndex = 0;
            while (batchController.getBatchLineIterator().hasNext()) {
                OutputStream os = new ByteArrayOutputStream();
                ODataBatchUtilities.readBatchPart(batchController, os, false);
                Object content = null;
                final Olingo4BatchRequest batchPartRequest = batchRequest.get(batchRequestIndex);
                final HttpResponse batchPartHttpResponse = constructBatchPartHttpResponse(new ByteArrayInputStream(((ByteArrayOutputStream)os).toByteArray()));
                final StatusLine batchPartStatusLine = batchPartHttpResponse.getStatusLine();
                final int batchPartLineStatusCode = batchPartStatusLine.getStatusCode();
                Map<String, String> batchPartHeaders = getHeadersValueMap(batchPartHttpResponse.getAllHeaders());
                if (batchPartRequest instanceof Olingo4BatchQueryRequest) {
                    Olingo4BatchQueryRequest batchPartQueryRequest = (Olingo4BatchQueryRequest)batchPartRequest;
                    final UriInfo uriInfo = parseUri(edm, batchPartQueryRequest.getResourcePath(), null, serviceUri);

                    if (HttpStatusCode.BAD_REQUEST.getStatusCode() <= batchPartLineStatusCode && batchPartLineStatusCode <= AbstractFutureCallback.NETWORK_CONNECT_TIMEOUT_ERROR) {
                        final ContentType responseContentType = getContentTypeHeader(batchPartHttpResponse);
                        content = odataReader.readError(batchPartHttpResponse.getEntity().getContent(), responseContentType);
                    } else if (batchPartLineStatusCode == HttpStatusCode.NO_CONTENT.getStatusCode()) {
                        // nothing to do if NO_CONTENT returning
                    } else {
                        content = readContent(uriInfo, batchPartHttpResponse.getEntity().getContent());
                    }

                    Olingo4BatchResponse batchPartResponse = new Olingo4BatchResponse(batchPartStatusLine.getStatusCode(), batchPartStatusLine.getReasonPhrase(), null,
                                                                                      batchPartHeaders, content);
                    batchResponse.add(batchPartResponse);
                } else if (batchPartRequest instanceof Olingo4BatchChangeRequest) {
                    Olingo4BatchChangeRequest batchPartChangeRequest = (Olingo4BatchChangeRequest)batchPartRequest;

                    if (batchPartLineStatusCode != HttpStatusCode.NO_CONTENT.getStatusCode()) {
                        if (HttpStatusCode.BAD_REQUEST.getStatusCode() <= batchPartLineStatusCode
                            && batchPartLineStatusCode <= AbstractFutureCallback.NETWORK_CONNECT_TIMEOUT_ERROR) {
                            final ContentType responseContentType = ContentType.parse(batchPartHttpResponse.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue());
                            content = odataReader.readError(response.getEntity().getContent(), responseContentType);
                        } else {
                            final UriInfo uriInfo = parseUri(edm, batchPartChangeRequest.getResourcePath()
                                                                  + (batchPartChangeRequest.getOperation() == Operation.CREATE ? CLIENT_ENTITY_FAKE_MARKER : ""),
                                                             null, serviceUri);
                            content = readContent(uriInfo, batchPartHttpResponse.getEntity().getContent());
                        }
                    }
                    Olingo4BatchResponse batchPartResponse = new Olingo4BatchResponse(batchPartStatusLine.getStatusCode(), batchPartStatusLine.getReasonPhrase(),
                                                                                      batchPartChangeRequest.getContentId(), batchPartHeaders, content);
                    batchResponse.add(batchPartResponse);
                } else {
                    throw new ODataException("Unsupported batch part request object type: " + batchPartRequest);
                }
                batchRequestIndex++;
            }

        } catch (IOException | HttpException e) {
            throw new ODataException(e);
        }
        return batchResponse;
    }

    private HttpResponse constructBatchPartHttpResponse(InputStream batchPartStream) throws IOException, HttpException {
        final LineIterator lines = IOUtils.lineIterator(batchPartStream, Constants.UTF8);
        final ByteArrayOutputStream headerOutputStream = new ByteArrayOutputStream();
        final ByteArrayOutputStream bodyOutputStream = new ByteArrayOutputStream();

        boolean startBatchPartHeader = false;
        boolean startBatchPartBody = false;

        // Iterate through lines in the batch part
        while (lines.hasNext()) {
            String line = lines.nextLine().trim();
            // Ignore all lines below HTTP/1.1 line
            if (line.startsWith(HttpVersion.HTTP)) {
                // This is the first header line
                startBatchPartHeader = true;
            }
            // Body starts with empty string after header lines
            if (startBatchPartHeader && StringUtils.isBlank(line)) {
                startBatchPartHeader = false;
                startBatchPartBody = true;
            }
            if (startBatchPartHeader) {
                // Write header to the output stream
                headerOutputStream.write(line.getBytes(Constants.UTF8));
                headerOutputStream.write(ODataStreamer.CRLF);
            } else if (startBatchPartBody && StringUtils.isNotBlank(line)) {
                // Write body to the output stream
                bodyOutputStream.write(line.getBytes(Constants.UTF8));
                bodyOutputStream.write(ODataStreamer.CRLF);
            }
        }

        // Prepare for parsing headers in to the HttpResponse object
        HttpTransportMetricsImpl metrics = new HttpTransportMetricsImpl();
        SessionInputBufferImpl sessionInputBuffer = new SessionInputBufferImpl(metrics, 2048);
        HttpResponseFactory responseFactory = new DefaultHttpResponseFactory();

        sessionInputBuffer.bind(new ByteArrayInputStream(headerOutputStream.toByteArray()));
        DefaultHttpResponseParser responseParser = new DefaultHttpResponseParser(sessionInputBuffer, new BasicLineParser(), responseFactory, MessageConstraints.DEFAULT);

        // Parse HTTP response and headers
        HttpResponse response = responseParser.parse();
        // Set body inside entity
        response.setEntity(new ByteArrayEntity(bodyOutputStream.toByteArray()));

        return response;
    }

    private Collection<String> getHeadersCollection(Header[] headers) {
        Collection<String> headersCollection = new ArrayList();
        for (Header header : Arrays.asList(headers)) {
            headersCollection.add(header.getValue());
        }
        return headersCollection;
    }

    private Map<String, String> getHeadersValueMap(Header[] headers) {
        Map<String, String> headersValueMap = new HashMap();
        for (Header header : Arrays.asList(headers)) {
            headersValueMap.put(header.getName(), header.getValue());
        }
        return headersValueMap;
    }

    private String createUri(String resourcePath) {
        return createUri(serviceUri, resourcePath, null);
    }

    private String createUri(String resourcePath, String queryOptions) {
        return createUri(serviceUri, resourcePath, queryOptions);
    }

    private String createUri(String resourceUri, String resourcePath, String queryOptions) {

        final StringBuilder absolutUri = new StringBuilder(resourceUri).append(SEPARATOR).append(resourcePath);
        if (queryOptions != null && !queryOptions.isEmpty()) {
            absolutUri.append("?" + queryOptions);
        }
        return absolutUri.toString();

    }

    private String concatQueryParams(final Map<String, String> queryParams) {
        final StringBuilder concatQuery = new StringBuilder("");
        if (queryParams != null && !queryParams.isEmpty()) {
            int nParams = queryParams.size();
            int index = 0;
            for (Map.Entry<String, String> entry : queryParams.entrySet()) {
                concatQuery.append(entry.getKey()).append('=').append(entry.getValue());
                if (++index < nParams) {
                    concatQuery.append('&');
                }
            }
        }
        return concatQuery.toString().replaceAll("  *", "%20");
    }

    private static UriInfo parseUri(Edm edm, String resourcePath, String queryOptions, String serviceUri) {
        Parser parser = new Parser(edm, OData.newInstance());
        UriInfo result;

        try {
            result = parser.parseUri(resourcePath, queryOptions, null, serviceUri);
        } catch (Exception e) {
            throw new IllegalArgumentException("parseUri (" + resourcePath + "," + queryOptions + "): " + e.getMessage(), e);
        }
        return result;
    }

    private static Map<String, String> headersToMap(final Header[] headers) {
        final Map<String, String> responseHeaders = new HashMap<>();
        for (Header header : headers) {
            responseHeaders.put(header.getName(), header.getValue());
        }
        return responseHeaders;
    }

    public void execute(HttpUriRequest httpUriRequest, ContentType contentType, final Map<String, String> endpointHttpHeaders, FutureCallback<HttpResponse> callback) {
        // add accept header when its not a form or multipart
        if (!ContentType.APPLICATION_FORM_URLENCODED.equals(contentType) && !contentType.toContentTypeString().startsWith(MULTIPART_MIME_TYPE)) {
            // otherwise accept what is being sent
            httpUriRequest.addHeader(HttpHeaders.ACCEPT, contentType.getType().toLowerCase() + "/" + contentType.getSubtype().toLowerCase());
            final String acceptCharset = contentType.getParameter(ContentType.PARAMETER_CHARSET);
            if (null != acceptCharset) {
                httpUriRequest.addHeader(HttpHeaders.ACCEPT_CHARSET, acceptCharset.toLowerCase());
            }
        }

        // is something being sent?
        if (httpUriRequest instanceof HttpEntityEnclosingRequestBase && httpUriRequest.getFirstHeader(HttpHeaders.CONTENT_TYPE) == null) {
            httpUriRequest.addHeader(HttpHeaders.CONTENT_TYPE, contentType.toString());
        }

        // set user specified custom headers
        if (ObjectHelper.isNotEmpty(httpHeaders)) {
            for (Map.Entry<String, String> entry : httpHeaders.entrySet()) {
                httpUriRequest.setHeader(entry.getKey(), entry.getValue());
            }
        }

        // set user specified endpoint headers
        if (ObjectHelper.isNotEmpty(endpointHttpHeaders)) {
            for (Map.Entry<String, String> entry : endpointHttpHeaders.entrySet()) {
                httpUriRequest.setHeader(entry.getKey(), entry.getValue());
            }
        }

        // add 'Accept-Charset' header to avoid BOM marker presents inside
        // response stream
        if (!httpUriRequest.containsHeader(HttpHeaders.ACCEPT_CHARSET)) {
            httpUriRequest.addHeader(HttpHeaders.ACCEPT_CHARSET, Constants.UTF8.toLowerCase());
        }

        // add client protocol version if not specified
        if (!httpUriRequest.containsHeader(HttpHeader.ODATA_VERSION)) {
            httpUriRequest.addHeader(HttpHeader.ODATA_VERSION, ODataServiceVersion.V40.toString());
        }
        if (!httpUriRequest.containsHeader(HttpHeader.ODATA_MAX_VERSION)) {
            httpUriRequest.addHeader(HttpHeader.ODATA_MAX_VERSION, ODataServiceVersion.V40.toString());
        }

        // execute request
        if (client instanceof CloseableHttpAsyncClient) {
            ((CloseableHttpAsyncClient)client).execute(httpUriRequest, callback);
        } else {
            // invoke the callback methods explicitly after executing the
            // request synchronously
            try {
                CloseableHttpResponse result = ((CloseableHttpClient)client).execute(httpUriRequest);
                callback.completed(result);
            } catch (IOException e) {
                callback.failed(e);
            }
        }
    }
}
