/**
 * 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.olingo2.api.impl;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;

import org.apache.camel.component.olingo2.api.Olingo2App;
import org.apache.camel.component.olingo2.api.Olingo2ResponseHandler;
import org.apache.camel.component.olingo2.api.batch.Olingo2BatchChangeRequest;
import org.apache.camel.component.olingo2.api.batch.Olingo2BatchQueryRequest;
import org.apache.camel.component.olingo2.api.batch.Olingo2BatchRequest;
import org.apache.camel.component.olingo2.api.batch.Olingo2BatchResponse;
import org.apache.camel.component.olingo2.api.batch.Operation;
import org.apache.http.Consts;
import org.apache.http.Header;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpResponse;
import org.apache.http.HttpVersion;
import org.apache.http.StatusLine;
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.HttpUriRequest;
import org.apache.http.concurrent.FutureCallback;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.InputStreamEntity;
import org.apache.http.entity.StringEntity;
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.BasicHttpResponse;
import org.apache.http.message.BasicStatusLine;
import org.apache.http.nio.client.util.HttpAsyncClientUtils;
import org.apache.olingo.odata2.api.ODataServiceVersion;
import org.apache.olingo.odata2.api.batch.BatchException;
import org.apache.olingo.odata2.api.client.batch.BatchChangeSet;
import org.apache.olingo.odata2.api.client.batch.BatchChangeSetPart;
import org.apache.olingo.odata2.api.client.batch.BatchPart;
import org.apache.olingo.odata2.api.client.batch.BatchQueryPart;
import org.apache.olingo.odata2.api.client.batch.BatchSingleResponse;
import org.apache.olingo.odata2.api.commons.HttpStatusCodes;
import org.apache.olingo.odata2.api.commons.ODataHttpHeaders;
import org.apache.olingo.odata2.api.edm.Edm;
import org.apache.olingo.odata2.api.edm.EdmEntityContainer;
import org.apache.olingo.odata2.api.edm.EdmEntitySet;
import org.apache.olingo.odata2.api.edm.EdmException;
import org.apache.olingo.odata2.api.edm.EdmProperty;
import org.apache.olingo.odata2.api.ep.EntityProvider;
import org.apache.olingo.odata2.api.ep.EntityProviderException;
import org.apache.olingo.odata2.api.ep.EntityProviderReadProperties;
import org.apache.olingo.odata2.api.ep.EntityProviderWriteProperties;
import org.apache.olingo.odata2.api.exception.ODataApplicationException;
import org.apache.olingo.odata2.api.exception.ODataException;
import org.apache.olingo.odata2.api.processor.ODataResponse;
import org.apache.olingo.odata2.api.uri.PathSegment;
import org.apache.olingo.odata2.api.uri.UriParser;

/**
 * Application API used by Olingo2 Component.
 */
public final class Olingo2AppImpl implements Olingo2App {

    public static final String METADATA = "$metadata";

    private static final String SEPARATOR = "/";

    private static final String BOUNDARY_PREFIX = "batch_";
    private static final String BOUNDARY_PARAMETER = "; boundary=";

    private static final ContentType METADATA_CONTENT_TYPE =
        ContentType.create("application/xml", Consts.UTF_8);
    private static final ContentType SERVICE_DOCUMENT_CONTENT_TYPE =
        ContentType.create("application/atomsvc+xml", Consts.UTF_8);
    private static final String BATCH_CONTENT_TYPE =
        ContentType.create("multipart/mixed").toString();

    private static final String BATCH = "$batch";
    private static final String MAX_DATA_SERVICE_VERSION = "Max" + ODataHttpHeaders.DATASERVICEVERSION;
    private static final String MULTIPART_MIME_TYPE = "multipart/";
    private static final ContentType TEXT_PLAIN_WITH_CS_UTF_8 = ContentType.TEXT_PLAIN.withCharset(Consts.UTF_8);

    private final CloseableHttpAsyncClient client;

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

    /**
     * Create Olingo2 Application with default HTTP configuration.
     */
    public Olingo2AppImpl(String serviceUri) {
        this(serviceUri, null);
    }

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

        if (builder == null) {
            this.client = HttpAsyncClients.createDefault();
        } else {
            this.client = builder.build();
        }
        this.client.start();
        this.contentType = ContentType.create("application/json", Consts.UTF_8);
    }

    @Override
    public void setServiceUri(String serviceUri) {
        if (serviceUri == null || serviceUri.isEmpty()) {
            throw new IllegalArgumentException("serviceUri");
        }
        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.toString();
    }

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

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

    @Override
    public <T> void read(final Edm edm, final String resourcePath, final Map<String, String> queryParams,
                         final Olingo2ResponseHandler<T> responseHandler) {

        final UriInfoWithType uriInfo = parseUri(edm, resourcePath, queryParams);

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

                @Override
                @SuppressWarnings("unchecked")
                public void onCompleted(HttpResponse result) throws IOException {

                    readContent(uriInfo, result.getEntity() != null ? result.getEntity().getContent() : null,
                        responseHandler);
                }

            });
    }

    private ContentType getResourceContentType(UriInfoWithType uriInfo) {
        ContentType resourceContentType;
        switch (uriInfo.getUriType()) {
        case URI0:
            // service document
            resourceContentType = SERVICE_DOCUMENT_CONTENT_TYPE;
            break;
        case URI8:
            // metadata
            resourceContentType = METADATA_CONTENT_TYPE;
            break;
        case URI4:
        case URI5:
            // is it a $value URI??
            if (uriInfo.isValue()) {
                // property value and $count
                resourceContentType = TEXT_PLAIN_WITH_CS_UTF_8;
            } else {
                resourceContentType = contentType;
            }
            break;
        case URI15:
        case URI16:
        case URI50A:
        case URI50B:
            // $count
            resourceContentType = TEXT_PLAIN_WITH_CS_UTF_8;
            break;
        default:
            resourceContentType = contentType;
        }
        return resourceContentType;
    }

    @Override
    public <T> void create(Edm edm, String resourcePath, Object data, Olingo2ResponseHandler<T> responseHandler) {
        final UriInfoWithType uriInfo = parseUri(edm, resourcePath, null);

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

    @Override
    public <T> void update(Edm edm, String resourcePath, Object data, Olingo2ResponseHandler<T> responseHandler) {
        final UriInfoWithType uriInfo = parseUri(edm, resourcePath, null);

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

    @Override
    public <T> void patch(Edm edm, String resourcePath, Object data, Olingo2ResponseHandler<T> responseHandler) {
        final UriInfoWithType uriInfo = parseUri(edm, resourcePath, null);

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

    @Override
    public <T> void merge(Edm edm, String resourcePath, Object data, Olingo2ResponseHandler<T> responseHandler) {
        final UriInfoWithType uriInfo = parseUri(edm, resourcePath, null);

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

    @Override
    public void batch(Edm edm, Object data, Olingo2ResponseHandler<List<Olingo2BatchResponse>> responseHandler) {
        final UriInfoWithType uriInfo = parseUri(edm, BATCH, null);

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

    @Override
    public void delete(String resourcePath, final Olingo2ResponseHandler<HttpStatusCodes> responseHandler) {

        execute(new HttpDelete(createUri(resourcePath)), contentType,
            new AbstractFutureCallback<HttpStatusCodes>(responseHandler) {
                @Override
                public void onCompleted(HttpResponse result) {
                    final StatusLine statusLine = result.getStatusLine();
                    responseHandler.onResponse(HttpStatusCodes.fromStatusCode(statusLine.getStatusCode()));
                }
            });
    }

    private <T> void readContent(UriInfoWithType uriInfo, InputStream content, Olingo2ResponseHandler<T> responseHandler) {
        try {
            responseHandler.onResponse(this.<T>readContent(uriInfo, content));
        } catch (EntityProviderException e) {
            responseHandler.onException(e);
        } catch (ODataApplicationException e) {
            responseHandler.onException(e);
        }
    }

    @SuppressWarnings("unchecked")
    private <T> T readContent(UriInfoWithType uriInfo, InputStream content)
        throws EntityProviderException, ODataApplicationException {
        T response;
        switch (uriInfo.getUriType()) {
        case URI0:
            // service document
            response = (T) EntityProvider.readServiceDocument(content, SERVICE_DOCUMENT_CONTENT_TYPE.toString());
            break;

        case URI8:
            // $metadata
            response = (T) EntityProvider.readMetadata(content, false);
            break;

        case URI7A:
            // link
            response = (T) EntityProvider.readLink(getContentType(), uriInfo.getTargetEntitySet(), content);
            break;

        case URI7B:
            // links
            response = (T) EntityProvider.readLinks(getContentType(), uriInfo.getTargetEntitySet(), content);
            break;

        case URI3:
            // complex property
            final List<EdmProperty> complexPropertyPath = uriInfo.getPropertyPath();
            final EdmProperty complexProperty = complexPropertyPath.get(complexPropertyPath.size() - 1);
            response = (T) EntityProvider.readProperty(getContentType(),
                complexProperty, content, EntityProviderReadProperties.init().build());
            break;

        case URI4:
        case URI5:
            // simple property
            final List<EdmProperty> simplePropertyPath = uriInfo.getPropertyPath();
            final EdmProperty simpleProperty = simplePropertyPath.get(simplePropertyPath.size() - 1);
            if (uriInfo.isValue()) {
                response = (T) EntityProvider.readPropertyValue(simpleProperty, content);
            } else {
                response = (T) EntityProvider.readProperty(getContentType(),
                    simpleProperty, content, EntityProviderReadProperties.init().build());
            }
            break;

        case URI15:
        case URI16:
        case URI50A:
        case URI50B:
            // $count
            final String stringCount = new String(EntityProvider.readBinary(content), Consts.UTF_8);
            response = (T) Long.valueOf(stringCount);
            break;

        case URI1:
        case URI6B:
            if (uriInfo.getCustomQueryOptions().containsKey("!deltatoken")) {
                // ODataDeltaFeed
                response = (T) EntityProvider.readDeltaFeed(
                    getContentType(),
                    uriInfo.getTargetEntitySet(), content,
                    EntityProviderReadProperties.init().build());
            } else {
                // ODataFeed
                response = (T) EntityProvider.readFeed(
                    getContentType(),
                    uriInfo.getTargetEntitySet(), content,
                    EntityProviderReadProperties.init().build());
            }
            break;

        case URI2:
        case URI6A:
            response = (T) EntityProvider.readEntry(
                getContentType(),
                uriInfo.getTargetEntitySet(),
                content,
                EntityProviderReadProperties.init().build());
            break;

        default:
            throw new ODataApplicationException("Unsupported resource type " + uriInfo.getTargetType(),
                Locale.ENGLISH);
        }

        return response;
    }

    private <T> void writeContent(final Edm edm, HttpEntityEnclosingRequestBase httpEntityRequest,
                                  final UriInfoWithType uriInfo, final Object content,
                                  final Olingo2ResponseHandler<T> responseHandler) {

        try {
            // process resource by UriType
            final ODataResponse response = writeContent(edm, uriInfo, content);

            // copy all response headers
            for (String header : response.getHeaderNames()) {
                httpEntityRequest.setHeader(header, response.getHeader(header));
            }

            // get (http) entity which is for default Olingo2 implementation an InputStream
            if (response.getEntity() instanceof InputStream) {
                httpEntityRequest.setEntity(new InputStreamEntity((InputStream) response.getEntity()));
/*
                // avoid sending it without a header field set
                if (!httpEntityRequest.containsHeader(HttpHeaders.CONTENT_TYPE)) {
                    httpEntityRequest.addHeader(HttpHeaders.CONTENT_TYPE, getContentType());
                }
*/
            }

            // execute HTTP request
            final Header requestContentTypeHeader = httpEntityRequest.getFirstHeader(HttpHeaders.CONTENT_TYPE);
            final ContentType requestContentType = requestContentTypeHeader != null
                ? ContentType.parse(requestContentTypeHeader.getValue()) : contentType;
            execute(httpEntityRequest, requestContentType, new AbstractFutureCallback<T>(responseHandler) {
                @SuppressWarnings("unchecked")
                @Override
                public void onCompleted(HttpResponse result)
                    throws IOException, EntityProviderException, BatchException, ODataApplicationException {

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

                    // look for no content, or no response body!!!
                    final boolean noEntity = result.getEntity() == null || result.getEntity().getContentLength() == 0;
                    if (statusCode == HttpStatusCodes.NO_CONTENT || noEntity) {
                        responseHandler.onResponse(
                            (T) HttpStatusCodes.fromStatusCode(result.getStatusLine().getStatusCode()));
                    } else {

                        switch (uriInfo.getUriType()) {
                        case URI9:
                            // $batch
                            final List<BatchSingleResponse> singleResponses = EntityProvider.parseBatchResponse(
                                result.getEntity().getContent(),
                                result.getFirstHeader(HttpHeaders.CONTENT_TYPE).getValue());

                            // parse batch response bodies
                            final List<Olingo2BatchResponse> responses = new ArrayList<Olingo2BatchResponse>();
                            Map<String, String> contentIdLocationMap = new HashMap<String, String>();

                            final List<Olingo2BatchRequest> batchRequests = (List<Olingo2BatchRequest>) content;
                            final Iterator<Olingo2BatchRequest> iterator = batchRequests.iterator();

                            for (BatchSingleResponse response : singleResponses) {
                                final Olingo2BatchRequest request = iterator.next();

                                if (request instanceof Olingo2BatchChangeRequest
                                    && ((Olingo2BatchChangeRequest) request).getContentId() != null) {

                                    contentIdLocationMap.put("$" + ((Olingo2BatchChangeRequest) request).getContentId(),
                                        response.getHeader(HttpHeaders.LOCATION));
                                }

                                try {
                                    responses.add(parseResponse(edm, contentIdLocationMap, request, response));
                                } catch (Exception e) {
                                    // report any parsing errors as error response
                                    responses.add(new Olingo2BatchResponse(
                                        Integer.parseInt(response.getStatusCode()),
                                        response.getStatusInfo(), response.getContentId(), response.getHeaders(),
                                        new ODataApplicationException(
                                            "Error parsing response for " + request + ": " + e.getMessage(),
                                            Locale.ENGLISH, e)));
                                }
                            }
                            responseHandler.onResponse((T) responses);
                            break;

                        case URI4:
                        case URI5:
                            // simple property
                            // get the response content as Object for $value or Map<String, Object> otherwise
                            final List<EdmProperty> simplePropertyPath = uriInfo.getPropertyPath();
                            final EdmProperty simpleProperty = simplePropertyPath.get(simplePropertyPath.size() - 1);
                            if (uriInfo.isValue()) {
                                responseHandler.onResponse(
                                    (T) EntityProvider.readPropertyValue(simpleProperty,
                                        result.getEntity().getContent()));
                            } else {
                                responseHandler.onResponse(
                                    (T) EntityProvider.readProperty(getContentType(), simpleProperty,
                                        result.getEntity().getContent(),
                                        EntityProviderReadProperties.init().build()));
                            }
                            break;

                        case URI3:
                            // complex property
                            // get the response content as Map<String, Object>
                            final List<EdmProperty> complexPropertyPath = uriInfo.getPropertyPath();
                            final EdmProperty complexProperty = complexPropertyPath.get(complexPropertyPath.size() - 1);
                            responseHandler.onResponse((T) EntityProvider.readProperty(getContentType(),
                                complexProperty, result
                                    .getEntity()
                                    .getContent(),
                                EntityProviderReadProperties
                                    .init().build()));
                            break;

                        case URI7A:
                            // $links with 0..1 cardinality property
                            // get the response content as String
                            final EdmEntitySet targetLinkEntitySet = uriInfo.getTargetEntitySet();
                            responseHandler.onResponse((T) EntityProvider.readLink(getContentType(),
                                targetLinkEntitySet, result
                                    .getEntity()
                                    .getContent()));
                            break;

                        case URI7B:
                            // $links with * cardinality property
                            // get the response content as java.util.List<String>
                            final EdmEntitySet targetLinksEntitySet = uriInfo.getTargetEntitySet();
                            responseHandler.onResponse((T) EntityProvider.readLinks(getContentType(),
                                targetLinksEntitySet,
                                result.getEntity()
                                    .getContent()));
                            break;

                        case URI1:
                        case URI2:
                        case URI6A:
                        case URI6B:
                            // Entity
                            // get the response content as an ODataEntry object
                            responseHandler.onResponse((T) EntityProvider.readEntry(response.getContentHeader(),
                                uriInfo.getTargetEntitySet(),
                                result.getEntity().getContent(),
                                EntityProviderReadProperties.init().build()));
                            break;

                        default:
                            throw new ODataApplicationException("Unsupported resource type " + uriInfo.getTargetType(),
                                Locale.ENGLISH);
                        }

                    }
                }
            });
        } catch (ODataException e) {
            responseHandler.onException(e);
        } catch (URISyntaxException e) {
            responseHandler.onException(e);
        } catch (UnsupportedEncodingException e) {
            responseHandler.onException(e);
        } catch (IOException e) {
            responseHandler.onException(e);
        }
    }

    private ODataResponse writeContent(Edm edm, UriInfoWithType uriInfo, Object content)
        throws ODataApplicationException, EdmException, EntityProviderException, URISyntaxException, IOException {

        String responseContentType = getContentType();
        ODataResponse response;

        switch (uriInfo.getUriType()) {
        case URI4:
        case URI5:
            // simple property
            final List<EdmProperty> simplePropertyPath = uriInfo.getPropertyPath();
            final EdmProperty simpleProperty = simplePropertyPath.get(simplePropertyPath.size() - 1);
            responseContentType = simpleProperty.getMimeType();
            if (uriInfo.isValue()) {
                response = EntityProvider.writePropertyValue(simpleProperty, content);
                responseContentType = TEXT_PLAIN_WITH_CS_UTF_8.toString();
            } else {
                response = EntityProvider.writeProperty(getContentType(), simpleProperty, content);
            }
            break;

        case URI3:
            // complex property
            final List<EdmProperty> complexPropertyPath = uriInfo.getPropertyPath();
            final EdmProperty complexProperty = complexPropertyPath.get(complexPropertyPath.size() - 1);
            response = EntityProvider.writeProperty(responseContentType, complexProperty, content);
            break;

        case URI7A:
            // $links with 0..1 cardinality property
            final EdmEntitySet targetLinkEntitySet = uriInfo.getTargetEntitySet();
            EntityProviderWriteProperties linkProperties =
                EntityProviderWriteProperties.serviceRoot(new URI(serviceUri + SEPARATOR)).build();
            @SuppressWarnings("unchecked")
            final Map<String, Object> linkMap = (Map<String, Object>) content;
            response = EntityProvider.writeLink(responseContentType, targetLinkEntitySet, linkMap, linkProperties);
            break;

        case URI7B:
            // $links with * cardinality property
            final EdmEntitySet targetLinksEntitySet = uriInfo.getTargetEntitySet();
            EntityProviderWriteProperties linksProperties =
                EntityProviderWriteProperties.serviceRoot(new URI(serviceUri + SEPARATOR)).build();
            @SuppressWarnings("unchecked")
            final List<Map<String, Object>> linksMap = (List<Map<String, Object>>) content;
            response = EntityProvider.writeLinks(responseContentType, targetLinksEntitySet, linksMap, linksProperties);
            break;

        case URI1:
        case URI2:
        case URI6A:
        case URI6B:
            // Entity
            final EdmEntitySet targetEntitySet = uriInfo.getTargetEntitySet();
            EntityProviderWriteProperties properties =
                EntityProviderWriteProperties.serviceRoot(new URI(serviceUri + SEPARATOR)).build();
            @SuppressWarnings("unchecked")
            final Map<String, Object> objectMap = (Map<String, Object>) content;
            response = EntityProvider.writeEntry(responseContentType, targetEntitySet, objectMap, properties);
            break;

        case URI9:
            // $batch
            @SuppressWarnings("unchecked")
            final List<Olingo2BatchRequest> batchParts = (List<Olingo2BatchRequest>) content;
            response = parseBatchRequest(edm, batchParts);
            break;

        default:
            // notify exception and return!!!
            throw new ODataApplicationException("Unsupported resource type " + uriInfo.getTargetType(),
                Locale.ENGLISH);
        }

        return response.getContentHeader() != null ? response
            : ODataResponse.fromResponse(response).contentHeader(responseContentType).build();
    }

    private ODataResponse parseBatchRequest(final Edm edm, final List<Olingo2BatchRequest> batchParts)
        throws IOException, EntityProviderException, ODataApplicationException, EdmException, URISyntaxException {

        // create Batch request from parts
        final ArrayList<BatchPart> parts = new ArrayList<BatchPart>();
        final ArrayList<BatchChangeSetPart> changeSetParts = new ArrayList<BatchChangeSetPart>();

        final Map<String, String> contentIdMap = new HashMap<String, String>();

        for (Olingo2BatchRequest batchPart : batchParts) {

            if (batchPart instanceof Olingo2BatchQueryRequest) {

                // need to add change set parts collected so far??
                if (!changeSetParts.isEmpty()) {
                    addChangeSetParts(parts, changeSetParts);
                    changeSetParts.clear();
                    contentIdMap.clear();
                }

                // add to request parts
                final UriInfoWithType uriInfo = parseUri(edm, batchPart.getResourcePath(), null);
                parts.add(createBatchQueryPart(uriInfo, (Olingo2BatchQueryRequest) batchPart));

            } else {

                // add to change set parts
                final BatchChangeSetPart changeSetPart = createBatchChangeSetPart(
                    edm, contentIdMap, (Olingo2BatchChangeRequest) batchPart);
                changeSetParts.add(changeSetPart);
            }
        }

        // add any remaining change set parts
        if (!changeSetParts.isEmpty()) {
            addChangeSetParts(parts, changeSetParts);
        }

        final String boundary = BOUNDARY_PREFIX + UUID.randomUUID();
        InputStream batchRequest = EntityProvider.writeBatchRequest(parts, boundary);
        // add two blank lines before all --batch boundaries
        // otherwise Olingo2 EntityProvider parser barfs in the server!!!
        final byte[] bytes = EntityProvider.readBinary(batchRequest);
        final String batchRequestBody = new String(bytes, Consts.UTF_8);
        batchRequest = new ByteArrayInputStream(batchRequestBody.replaceAll(
            "--(batch_)", "\r\n\r\n--$1").getBytes(Consts.UTF_8));

        final String contentHeader = BATCH_CONTENT_TYPE + BOUNDARY_PARAMETER + boundary;
        return ODataResponse.entity(batchRequest).contentHeader(contentHeader).build();
    }

    private void addChangeSetParts(ArrayList<BatchPart> parts, ArrayList<BatchChangeSetPart> changeSetParts) {
        final BatchChangeSet changeSet = BatchChangeSet.newBuilder().build();
        for (BatchChangeSetPart changeSetPart : changeSetParts) {
            changeSet.add(changeSetPart);
        }
        parts.add(changeSet);
    }

    private BatchChangeSetPart createBatchChangeSetPart(Edm edm, Map<String, String> contentIdMap,
                                                        Olingo2BatchChangeRequest batchRequest)
        throws EdmException, URISyntaxException, EntityProviderException, IOException, ODataApplicationException {

        // build body string
        String resourcePath = batchRequest.getResourcePath();
        // is it a referenced entity?
        if (resourcePath.startsWith("$")) {
            resourcePath = replaceContentId(edm, resourcePath, contentIdMap);
        }

        final UriInfoWithType uriInfo = parseUri(edm, resourcePath, null);

        // serialize data into ODataResponse object, if set in request and this is not a DELETE request
        final Map<String, String> headers = new HashMap<String, String>();
        byte[] body = null;

        if (batchRequest.getBody() != null
            && !Operation.DELETE.equals(batchRequest.getOperation())) {

            final ODataResponse response = writeContent(edm, uriInfo, batchRequest.getBody());
            // copy response headers
            for (String header : response.getHeaderNames()) {
                headers.put(header, response.getHeader(header));
            }

            // get (http) entity which is for default Olingo2 implementation an InputStream
            body = response.getEntity() instanceof InputStream
                ? EntityProvider.readBinary((InputStream) response.getEntity()) : null;
            if (body != null) {
                headers.put(HttpHeaders.CONTENT_LENGTH, String.valueOf(body.length));
            }
        }

        // Olingo is sensitive to batch part charset case!!
        headers.put(HttpHeaders.ACCEPT, getResourceContentType(uriInfo).toString().toLowerCase());
        if (!headers.containsKey(HttpHeaders.CONTENT_TYPE)) {
            headers.put(HttpHeaders.CONTENT_TYPE, getContentType());
        }

        // add request headers
        headers.putAll(batchRequest.getHeaders());

        final String contentId = batchRequest.getContentId();
        if (contentId != null) {
            contentIdMap.put("$" + contentId, resourcePath);
        }
        return BatchChangeSetPart.uri(createBatchUri(batchRequest))
            .method(batchRequest.getOperation().getHttpMethod())
            .contentId(contentId)
            .headers(headers)
            .body(body == null ? null : new String(body, Consts.UTF_8)).build();
    }

    private BatchQueryPart createBatchQueryPart(UriInfoWithType uriInfo, Olingo2BatchQueryRequest batchRequest) {

        final Map<String, String> headers = new HashMap<String, String>(batchRequest.getHeaders());
        if (!headers.containsKey(HttpHeaders.ACCEPT)) {
            // Olingo is sensitive to batch part charset case!!
            headers.put(HttpHeaders.ACCEPT, getResourceContentType(uriInfo).toString().toLowerCase());
        }

        return BatchQueryPart.method("GET")
            .uri(createBatchUri(batchRequest))
            .headers(headers)
            .build();
    }

    private static String replaceContentId(Edm edm, String entityReference, Map<String, String> contentIdMap) throws EdmException {
        final int pathSeparator = entityReference.indexOf('/');
        final StringBuilder referencedEntity;
        if (pathSeparator == -1) {
            referencedEntity = new StringBuilder(contentIdMap.get(entityReference));
        } else {
            referencedEntity = new StringBuilder(contentIdMap.get(entityReference.substring(0, pathSeparator)));
        }

        // create a dummy entity location by adding a dummy key predicate
        // look for a Container name if available
        String referencedEntityName = referencedEntity.toString();
        final int containerSeparator = referencedEntityName.lastIndexOf('.');
        final EdmEntityContainer entityContainer;
        if (containerSeparator != -1) {
            final String containerName = referencedEntityName.substring(0, containerSeparator);
            referencedEntityName = referencedEntityName.substring(containerSeparator + 1);
            entityContainer = edm.getEntityContainer(containerName);
            if (entityContainer == null) {
                throw new IllegalArgumentException("EDM does not have entity container " + containerName);
            }
        } else {
            entityContainer = edm.getDefaultEntityContainer();
            if (entityContainer == null) {
                throw new IllegalArgumentException("EDM does not have a default entity container"
                    + ", use a fully qualified entity set name");
            }
        }
        final EdmEntitySet entitySet = entityContainer.getEntitySet(referencedEntityName);
        final List<EdmProperty> keyProperties = entitySet.getEntityType().getKeyProperties();

        if (keyProperties.size() == 1) {
            referencedEntity.append("('dummy')");
        } else {
            referencedEntity.append("(");
            for (EdmProperty keyProperty : keyProperties) {
                referencedEntity.append(keyProperty.getName()).append('=').append("'dummy',");
            }
            referencedEntity.deleteCharAt(referencedEntity.length() - 1);
            referencedEntity.append(')');
        }

        return pathSeparator == -1 ? referencedEntityName
            : referencedEntity.append(entityReference.substring(pathSeparator)).toString();
    }

    private Olingo2BatchResponse parseResponse(Edm edm, Map<String, String> contentIdLocationMap,
                                              Olingo2BatchRequest request, BatchSingleResponse response)
        throws EntityProviderException, ODataApplicationException {

        // validate HTTP status
        final int statusCode = Integer.parseInt(response.getStatusCode());
        final String statusInfo = response.getStatusInfo();

        final BasicHttpResponse httpResponse = new BasicHttpResponse(new BasicStatusLine(HttpVersion.HTTP_1_1,
            statusCode, statusInfo));
        final Map<String, String> headers = response.getHeaders();
        for (Map.Entry<String, String> entry : headers.entrySet()) {
            httpResponse.setHeader(entry.getKey(), entry.getValue());
        }

        ByteArrayInputStream content = null;
        try {
            if (response.getBody() != null) {
                final ContentType partContentType = receiveWithCharsetParameter(ContentType.parse(
                    headers.get(HttpHeaders.CONTENT_TYPE)), Consts.UTF_8);
                final String charset = partContentType.getCharset().toString();

                final String body = response.getBody();
                content = body != null ? new ByteArrayInputStream(body.getBytes(charset)) : null;

                httpResponse.setEntity(new StringEntity(body, charset));
            }

            AbstractFutureCallback.checkStatus(httpResponse);
        } catch (ODataApplicationException e) {
            return new Olingo2BatchResponse(
                statusCode, statusInfo, response.getContentId(),
                response.getHeaders(), e);
        } catch (UnsupportedEncodingException e) {
            return new Olingo2BatchResponse(
                statusCode, statusInfo, response.getContentId(),
                response.getHeaders(), e);
        }

        // resolve resource path and query params and parse batch part uri
        final String resourcePath = request.getResourcePath();
        final String resolvedResourcePath;
        if (resourcePath.startsWith("$") && !(METADATA.equals(resourcePath) || BATCH.equals(resourcePath))) {
            resolvedResourcePath = findLocation(resourcePath, contentIdLocationMap);
        } else {
            final String resourceLocation = response.getHeader(HttpHeaders.LOCATION);
            resolvedResourcePath = resourceLocation != null
                ? resourceLocation.substring(serviceUri.length()) : resourcePath;
        }
        final Map<String, String> resolvedQueryParams = request instanceof Olingo2BatchQueryRequest
            ? ((Olingo2BatchQueryRequest) request).getQueryParams() : null;
        final UriInfoWithType uriInfo = parseUri(edm, resolvedResourcePath, resolvedQueryParams);

        // resolve response content
        final Object resolvedContent = content != null ? readContent(uriInfo, content) : null;

        return new Olingo2BatchResponse(statusCode, statusInfo, response.getContentId(), response.getHeaders(),
            resolvedContent);
    }

    private ContentType receiveWithCharsetParameter(ContentType contentType, Charset charset) {
        if (contentType.getCharset() != null) {
            return contentType;
        }
        final String mimeType = contentType.getMimeType();
        if (mimeType.equals(ContentType.TEXT_PLAIN.getMimeType())
            || AbstractFutureCallback.ODATA_MIME_TYPE.matcher(mimeType).matches()) {
            return contentType.withCharset(charset);
        }
        return contentType;
    }

    private String findLocation(String resourcePath, Map<String, String> contentIdLocationMap) {
        final int pathSeparator = resourcePath.indexOf('/');
        if (pathSeparator == -1) {
            return contentIdLocationMap.get(resourcePath);
        } else {
            return contentIdLocationMap.get(resourcePath.substring(0, pathSeparator))
                + resourcePath.substring(pathSeparator);
        }

    }

    private String createBatchUri(Olingo2BatchRequest part) {
        String result;
        if (part instanceof Olingo2BatchQueryRequest) {
            final Olingo2BatchQueryRequest queryPart = (Olingo2BatchQueryRequest) part;
            result = createUri(queryPart.getResourcePath(), queryPart.getQueryParams());
        } else {
            result = createUri(part.getResourcePath());
        }
        // strip base URI
        return result.substring(serviceUri.length() + 1);
    }

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

    private String createUri(String resourcePath, Map<String, String> queryParams) {

        final StringBuilder absolutUri = new StringBuilder(serviceUri).append(SEPARATOR).append(resourcePath);
        if (queryParams != null && !queryParams.isEmpty()) {
            absolutUri.append("/?");
            int nParams = queryParams.size();
            int index = 0;
            for (Map.Entry<String, String> entry : queryParams.entrySet()) {
                absolutUri.append(entry.getKey()).append('=').append(entry.getValue());
                if (++index < nParams) {
                    absolutUri.append('&');
                }
            }
        }
        return absolutUri.toString();
    }

    private static UriInfoWithType parseUri(Edm edm, String resourcePath, Map<String, String> queryParams) {
        UriInfoWithType result;
        try {
            final List<PathSegment> pathSegments = new ArrayList<PathSegment>();
            final String[] segments = new URI(resourcePath).getPath().split(SEPARATOR);
            if (queryParams == null) {
                queryParams = Collections.emptyMap();
            }
            for (String segment : segments) {
                if (segment.indexOf(';') == -1) {

                    pathSegments.add(new ODataPathSegmentImpl(segment, null));
                } else {

                    // handle matrix params in path segment
                    final String[] splitSegment = segment.split(";");
                    segment = splitSegment[0];

                    Map<String, List<String>> matrixParams = new HashMap<String, List<String>>();
                    for (int i = 1; i < splitSegment.length; i++) {
                        final String[] param = splitSegment[i].split("=");
                        List<String> values = matrixParams.get(param[0]);
                        if (values == null) {
                            values = new ArrayList<String>();
                            matrixParams.put(param[0], values);
                        }
                        if (param[1].indexOf(',') == -1) {
                            values.add(param[1]);
                        } else {
                            values.addAll(Arrays.asList(param[1].split(",")));
                        }
                    }
                    pathSegments.add(new ODataPathSegmentImpl(segment, matrixParams));
                }
            }
            result = new UriInfoWithType(UriParser.parse(edm, pathSegments, queryParams), resourcePath);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException("resourcePath: " + e.getMessage(), e);
        } catch (ODataException e) {
            throw new IllegalArgumentException("resourcePath: " + e.getMessage(), e);
        }

        return result;
    }

    /**
     * public for unit test, not to be used otherwise
     */
    public void execute(HttpUriRequest httpUriRequest, ContentType contentType,
                        FutureCallback<HttpResponse> callback) {

        // add accept header when its not a form or multipart
        final String contentTypeString = contentType.toString();
        if (!ContentType.APPLICATION_FORM_URLENCODED.getMimeType().equals(contentType.getMimeType())
            && !contentType.getMimeType().startsWith(MULTIPART_MIME_TYPE)) {
            // otherwise accept what is being sent
            httpUriRequest.addHeader(HttpHeaders.ACCEPT, contentTypeString);
        }
        // is something being sent?
        if (httpUriRequest instanceof HttpEntityEnclosingRequestBase
            && httpUriRequest.getFirstHeader(HttpHeaders.CONTENT_TYPE) == null) {
            httpUriRequest.addHeader(HttpHeaders.CONTENT_TYPE, contentTypeString);
        }

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

        // add client protocol version if not specified
        if (!httpUriRequest.containsHeader(ODataHttpHeaders.DATASERVICEVERSION)) {
            httpUriRequest.addHeader(ODataHttpHeaders.DATASERVICEVERSION, ODataServiceVersion.V20);
        }
        if (!httpUriRequest.containsHeader(MAX_DATA_SERVICE_VERSION)) {
            httpUriRequest.addHeader(MAX_DATA_SERVICE_VERSION, ODataServiceVersion.V30);
        }

        // execute request
        client.execute(httpUriRequest, callback);
    }

}
