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

import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

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.camel.component.olingo2.api.impl.Olingo2AppImpl;
import org.apache.camel.component.olingo2.api.impl.SystemQueryOption;
import org.apache.camel.test.AvailablePortFinder;
import org.apache.http.entity.ContentType;
import org.apache.olingo.odata2.api.commons.HttpStatusCodes;
import org.apache.olingo.odata2.api.edm.Edm;
import org.apache.olingo.odata2.api.edm.EdmEntitySet;
import org.apache.olingo.odata2.api.edm.EdmEntitySetInfo;
import org.apache.olingo.odata2.api.ep.EntityProvider;
import org.apache.olingo.odata2.api.ep.EntityProviderReadProperties;
import org.apache.olingo.odata2.api.ep.entry.ODataEntry;
import org.apache.olingo.odata2.api.ep.feed.ODataDeltaFeed;
import org.apache.olingo.odata2.api.ep.feed.ODataFeed;
import org.apache.olingo.odata2.api.servicedocument.Collection;
import org.apache.olingo.odata2.api.servicedocument.ServiceDocument;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

/**
 * Integration test for {@link org.apache.camel.component.olingo2.api.impl.Olingo2AppImpl}
 * using the sample Olingo2 Server dynamically downloaded and started during the test.
 */
public class Olingo2AppAPITest {

    private static final Logger LOG = LoggerFactory.getLogger(Olingo2AppAPITest.class);
    private static final int PORT = AvailablePortFinder.getNextAvailable();  

    private static final long TIMEOUT = 10;

    private static final String MANUFACTURERS = "Manufacturers";
    private static final String FQN_MANUFACTURERS = "DefaultContainer.Manufacturers";
    private static final String ADDRESS = "Address";
    private static final String CARS = "Cars";

    private static final String TEST_KEY = "'1'";
    private static final String TEST_CREATE_KEY = "'123'";
    private static final String TEST_MANUFACTURER = FQN_MANUFACTURERS + "(" + TEST_KEY + ")";
    private static final String TEST_CREATE_MANUFACTURER = MANUFACTURERS + "(" + TEST_CREATE_KEY + ")";

    private static final String TEST_RESOURCE_CONTENT_ID = "1";
    private static final String TEST_RESOURCE = "$" + TEST_RESOURCE_CONTENT_ID;

    private static final char NEW_LINE = '\n';
    private static final String TEST_CAR = "Manufacturers('1')/Cars('1')";
    private static final String TEST_MANUFACTURER_FOUNDED_PROPERTY = "Manufacturers('1')/Founded";
    private static final String TEST_MANUFACTURER_FOUNDED_VALUE = "Manufacturers('1')/Founded/$value";
    private static final String FOUNDED_PROPERTY = "Founded";
    private static final String TEST_MANUFACTURER_ADDRESS_PROPERTY = "Manufacturers('1')/Address";
    private static final String TEST_MANUFACTURER_LINKS_CARS = "Manufacturers('1')/$links/Cars";
    private static final String TEST_CAR_LINK_MANUFACTURER = "Cars('1')/$links/Manufacturer";
    private static final String COUNT_OPTION = "/$count";

    private static final String TEST_SERVICE_URL = "http://localhost:" + PORT + "/MyFormula.svc";
    //    private static final String TEST_SERVICE_URL = "http://localhost:8080/cars-annotations-sample/MyFormula.svc";
//    private static final ContentType TEST_FORMAT = ContentType.APPLICATION_XML_CS_UTF_8;
    private static final ContentType TEST_FORMAT = ContentType.APPLICATION_JSON;
    private static final String TEST_FORMAT_STRING = TEST_FORMAT.toString();
//    private static final Pattern LINK_PATTERN = Pattern.compile("[^(]+\\('([^']+)'\\)");
    private static final String ID_PROPERTY = "Id";

    private static Olingo2App olingoApp;
    private static Edm edm;
    private static Map<String, EdmEntitySet> edmEntitySetMap;

    private static Olingo2SampleServer server;

    @BeforeClass
    public static void beforeClass() throws Exception {
        startServers(PORT);
        Olingo2SampleServer.generateSampleData(TEST_SERVICE_URL);
        setupClient();
    }

    @AfterClass
    public static void afterClass() throws Exception {
        if (olingoApp != null) {
            olingoApp.close();
        }
        if (server != null) {
            server.stop();
            server.destroy();
        }
    }

    protected static void startServers(int port) throws Exception {
        server = new Olingo2SampleServer(port, "/olingo2_ref");
        server.start();
    }

    protected static void setupClient() throws Exception {
        olingoApp = new Olingo2AppImpl(TEST_SERVICE_URL + "/");
        olingoApp.setContentType(TEST_FORMAT_STRING);

        LOG.info("Read Edm ");
        final TestOlingo2ResponseHandler<Edm> responseHandler = new TestOlingo2ResponseHandler<Edm>();

        olingoApp.read(null, Olingo2AppImpl.METADATA, null, responseHandler);

        edm = responseHandler.await();
        LOG.info("Read default EntityContainer:  {}", responseHandler.await().getDefaultEntityContainer().getName());

        edmEntitySetMap = new HashMap<String, EdmEntitySet>();
        for (EdmEntitySet ees : edm.getEntitySets()) {
            edmEntitySetMap.put(ees.getName(), ees);
        }

        // wait for generated data to be registered in server
        Thread.sleep(2000);
    }

    @Test
    public void testServiceDocument() throws Exception {
        final TestOlingo2ResponseHandler<ServiceDocument> responseHandler =
            new TestOlingo2ResponseHandler<ServiceDocument>();

        olingoApp.read(null, "", null, responseHandler);

        final ServiceDocument serviceDocument = responseHandler.await();
        final List<Collection> collections = serviceDocument.getAtomInfo().getWorkspaces().get(0).getCollections();
        assertEquals("Service Atom Collections", 3, collections.size());
        LOG.info("Service Atom Collections:  {}", collections);

        final List<EdmEntitySetInfo> entitySetsInfo = serviceDocument.getEntitySetsInfo();
        assertEquals("Service Entity Sets", 3, entitySetsInfo.size());
        LOG.info("Service Document Entries:  {}", entitySetsInfo);
    }

    @Test
    public void testReadFeed() throws Exception {
        final TestOlingo2ResponseHandler<ODataFeed> responseHandler = new TestOlingo2ResponseHandler<ODataFeed>();

        olingoApp.read(edm, MANUFACTURERS, null, responseHandler);

        final ODataFeed dataFeed = responseHandler.await();
        assertNotNull("Data feed", dataFeed);
        LOG.info("Entries:  {}", prettyPrint(dataFeed));
    }

    @Test
    public void testReadUnparsedFeed() throws Exception {
        final TestOlingo2ResponseHandler<InputStream> responseHandler = new TestOlingo2ResponseHandler<InputStream>();

        olingoApp.uread(edm, MANUFACTURERS, null, responseHandler);

        final InputStream rawfeed = responseHandler.await();
        assertNotNull("Data feed", rawfeed);
        // for this test, we just let EP to verify the stream data 
        final ODataFeed dataFeed = EntityProvider.readFeed(TEST_FORMAT_STRING, edmEntitySetMap.get(MANUFACTURERS), 
                                                           rawfeed, EntityProviderReadProperties.init().build());
        LOG.info("Entries:  {}", prettyPrint(dataFeed));
    }

    @Test
    public void testReadEntry() throws Exception {
        final TestOlingo2ResponseHandler<ODataEntry> responseHandler = new TestOlingo2ResponseHandler<ODataEntry>();

        olingoApp.read(edm, TEST_MANUFACTURER, null, responseHandler);
        ODataEntry entry = responseHandler.await();
        LOG.info("Single Entry:  {}", prettyPrint(entry));

        responseHandler.reset();

        olingoApp.read(edm, TEST_CAR, null, responseHandler);
        entry = responseHandler.await();
        LOG.info("Single Entry:  {}", prettyPrint(entry));

        responseHandler.reset();
        final Map<String, String> queryParams = new HashMap<String, String>();
        queryParams.put(SystemQueryOption.$expand.toString(), CARS);

        olingoApp.read(edm, TEST_MANUFACTURER, queryParams, responseHandler);

        ODataEntry entryExpanded = responseHandler.await();
        LOG.info("Single Entry with expanded Cars relation:  {}", prettyPrint(entryExpanded));
    }

    @Test
    public void testReadUnparsedEntry() throws Exception {
        final TestOlingo2ResponseHandler<InputStream> responseHandler = new TestOlingo2ResponseHandler<InputStream>();

        olingoApp.uread(edm, TEST_MANUFACTURER, null, responseHandler);
        InputStream rawentry = responseHandler.await();
        ODataEntry entry = EntityProvider.readEntry(TEST_FORMAT_STRING, edmEntitySetMap.get(MANUFACTURERS), 
                                                    rawentry, EntityProviderReadProperties.init().build());
        LOG.info("Single Entry:  {}", prettyPrint(entry));

        responseHandler.reset();

        olingoApp.uread(edm, TEST_CAR, null, responseHandler);
        rawentry = responseHandler.await();
        entry = EntityProvider.readEntry(TEST_FORMAT_STRING, edmEntitySetMap.get(CARS),
                                         rawentry, EntityProviderReadProperties.init().build());
        LOG.info("Single Entry:  {}", prettyPrint(entry));

        responseHandler.reset();
        final Map<String, String> queryParams = new HashMap<String, String>();
        queryParams.put(SystemQueryOption.$expand.toString(), CARS);

        olingoApp.uread(edm, TEST_MANUFACTURER, queryParams, responseHandler);

        rawentry = responseHandler.await();
        ODataEntry entryExpanded = EntityProvider.readEntry(TEST_FORMAT_STRING, edmEntitySetMap.get(MANUFACTURERS), 
                                                            rawentry, EntityProviderReadProperties.init().build());
        LOG.info("Single Entry with expanded Cars relation:  {}", prettyPrint(entryExpanded));
    }

    @Test
    public void testReadUpdateProperties() throws Exception {
        // test simple property Manufacturer.Founded
        final TestOlingo2ResponseHandler<Map<String, Object>> propertyHandler =
            new TestOlingo2ResponseHandler<Map<String, Object>>();

        olingoApp.read(edm, TEST_MANUFACTURER_FOUNDED_PROPERTY, null, propertyHandler);

        Calendar founded = (Calendar) propertyHandler.await().get(FOUNDED_PROPERTY);
        LOG.info("Founded property {}", founded.toString());

        final TestOlingo2ResponseHandler<Calendar> valueHandler = new TestOlingo2ResponseHandler<Calendar>();

        olingoApp.read(edm, TEST_MANUFACTURER_FOUNDED_VALUE, null, valueHandler);

        founded = valueHandler.await();
        LOG.info("Founded property {}", founded.toString());

        final TestOlingo2ResponseHandler<HttpStatusCodes> statusHandler =
            new TestOlingo2ResponseHandler<HttpStatusCodes>();
        final HashMap<String, Object> properties = new HashMap<String, Object>();
        properties.put(FOUNDED_PROPERTY, new Date());

//        olingoApp.update(edm, TEST_MANUFACTURER_FOUNDED_PROPERTY, properties, statusHandler);
        // requires a plain Date for XML
        olingoApp.update(edm, TEST_MANUFACTURER_FOUNDED_PROPERTY, new Date(), statusHandler);

        LOG.info("Founded property updated with status {}", statusHandler.await().getStatusCode());

        statusHandler.reset();

        olingoApp.update(edm, TEST_MANUFACTURER_FOUNDED_VALUE, new Date(), statusHandler);

        LOG.info("Founded property updated with status {}", statusHandler.await().getStatusCode());

        // test complex property Manufacturer.Address
        propertyHandler.reset();

        olingoApp.read(edm, TEST_MANUFACTURER_ADDRESS_PROPERTY, null, propertyHandler);

        final Map<String, Object> address = propertyHandler.await();
        LOG.info("Address property {}", prettyPrint(address, 0));

        statusHandler.reset();

        address.clear();
        // Olingo2 sample server MERGE/PATCH behaves like PUT!!!
//        address.put("Street", "Main Street");
        address.put("Street", "Star Street 137");
        address.put("City", "Stuttgart");
        address.put("ZipCode", "70173");
        address.put("Country", "Germany");

//        olingoApp.patch(edm, TEST_MANUFACTURER_ADDRESS_PROPERTY, address, statusHandler);
        olingoApp.merge(edm, TEST_MANUFACTURER_ADDRESS_PROPERTY, address, statusHandler);

        LOG.info("Address property updated with status {}", statusHandler.await().getStatusCode());
    }

    @Test
    public void testReadDeleteCreateLinks() throws Exception {
        final TestOlingo2ResponseHandler<List<String>> linksHandler = new TestOlingo2ResponseHandler<List<String>>();

        olingoApp.read(edm, TEST_MANUFACTURER_LINKS_CARS, null, linksHandler);

        final List<String> links = linksHandler.await();
        assertFalse(links.isEmpty());
        LOG.info("Read links: {}", links);

        final TestOlingo2ResponseHandler<String> linkHandler = new TestOlingo2ResponseHandler<String>();

        olingoApp.read(edm, TEST_CAR_LINK_MANUFACTURER, null, linkHandler);

        final String link = linkHandler.await();
        LOG.info("Read link: {}", link);

//Deleting relationships through links is not supported in Olingo2 at the time of writing this test
/*
        final TestOlingo2ResponseHandler<HttpStatusCodes> statusHandler =
            new TestOlingo2ResponseHandler<HttpStatusCodes>();

        final ArrayList<Map<String, Object>> carKeys = new ArrayList<Map<String, Object>>();
        for (String carLink : links) {
            final Matcher matcher = LINK_PATTERN.matcher(carLink);
            assertTrue("Link pattern " + carLink, matcher.matches());
            final String carId = matcher.group(1);

            final HashMap<String, Object> keys = new HashMap<String, Object>();
            keys.put(ID_PROPERTY, carId);
            carKeys.add(keys);

            // delete manufacturer->car link
            statusHandler.reset();
            final String resourcePath = TEST_MANUFACTURER_LINKS_CARS + "('" + carId + "')";
            olingoApp.delete(resourcePath, statusHandler);

            assertEquals("Delete car link " + resourcePath, HttpStatusCodes.OK.getStatusCode(),
                statusHandler.await().getStatusCode());
        }

        // add links to all Cars
        statusHandler.reset();
        olingoApp.create(edm, TEST_MANUFACTURER_LINKS_CARS, carKeys, statusHandler);

        assertEquals("Links update", HttpStatusCodes.ACCEPTED.getStatusCode(), statusHandler.await().getStatusCode());

        // delete car->manufacturer link
        statusHandler.reset();
        olingoApp.delete(TEST_CAR_LINK_MANUFACTURER, statusHandler);

        assertEquals("Delete manufacturer link " + TEST_CAR_LINK_MANUFACTURER, HttpStatusCodes.OK.getStatusCode(),
            statusHandler.await().getStatusCode());

        // add link to Manufacturer
        statusHandler.reset();
        final HashMap<String, Object> manufacturerKey = new HashMap<String, Object>();
        manufacturerKey.put(ID_PROPERTY, "1");

        olingoApp.create(edm, TEST_CAR_LINK_MANUFACTURER, manufacturerKey, statusHandler);

        assertEquals("Link update", HttpStatusCodes.ACCEPTED.getStatusCode(), statusHandler.await().getStatusCode());
*/
    }

    @Test
    public void testReadCount() throws Exception {
        final TestOlingo2ResponseHandler<Long> countHandler = new TestOlingo2ResponseHandler<Long>();

        olingoApp.read(edm, MANUFACTURERS + COUNT_OPTION, null, countHandler);

        LOG.info("Manufacturers count: {}", countHandler.await());

        countHandler.reset();
        olingoApp.read(edm, TEST_MANUFACTURER + COUNT_OPTION, null, countHandler);

        LOG.info("Manufacturer count: {}", countHandler.await());

        countHandler.reset();
        olingoApp.read(edm, TEST_MANUFACTURER_LINKS_CARS + COUNT_OPTION, null, countHandler);

        LOG.info("Manufacturers links count: {}", countHandler.await());

        countHandler.reset();
        olingoApp.read(edm, TEST_CAR_LINK_MANUFACTURER + COUNT_OPTION, null, countHandler);

        LOG.info("Manufacturer link count: {}", countHandler.await());
    }

    @Test
    public void testCreateUpdateDeleteEntry() throws Exception {

        // create entry to update
        final TestOlingo2ResponseHandler<ODataEntry> entryHandler = new TestOlingo2ResponseHandler<ODataEntry>();

        olingoApp.create(edm, MANUFACTURERS, getEntityData(), entryHandler);

        ODataEntry createdEntry = entryHandler.await();
        LOG.info("Created Entry:  {}", prettyPrint(createdEntry));

        Map<String, Object> data = getEntityData();
        @SuppressWarnings("unchecked")
        Map<String, Object> address = (Map<String, Object>) data.get(ADDRESS);

        data.put("Name", "MyCarManufacturer Renamed");
        address.put("Street", "Main Street");
        final TestOlingo2ResponseHandler<HttpStatusCodes> statusHandler =
            new TestOlingo2ResponseHandler<HttpStatusCodes>();

        olingoApp.update(edm, TEST_CREATE_MANUFACTURER, data, statusHandler);
        statusHandler.await();

        statusHandler.reset();
        data.put("Name", "MyCarManufacturer Patched");
        olingoApp.patch(edm, TEST_CREATE_MANUFACTURER, data, statusHandler);
        statusHandler.await();

        entryHandler.reset();
        olingoApp.read(edm, TEST_CREATE_MANUFACTURER, null, entryHandler);

        ODataEntry updatedEntry = entryHandler.await();
        LOG.info("Updated Entry successfully:  {}", prettyPrint(updatedEntry));

        statusHandler.reset();
        olingoApp.delete(TEST_CREATE_MANUFACTURER,  statusHandler);

        HttpStatusCodes statusCode = statusHandler.await();
        LOG.info("Deletion of Entry was successful:  {}: {}", statusCode.getStatusCode(), statusCode.getInfo());

        try {
            LOG.info("Verify Delete Entry");

            entryHandler.reset();
            olingoApp.read(edm, TEST_CREATE_MANUFACTURER, null, entryHandler);

            entryHandler.await();
            fail("Entry not deleted!");
        } catch (Exception e) {
            LOG.info("Deleted entry not found: {}", e.getMessage());
        }
    }

    @Test
    public void testBatchRequest() throws Exception {

        final List<Olingo2BatchRequest> batchParts = new ArrayList<Olingo2BatchRequest>();

        // Edm query
        batchParts.add(Olingo2BatchQueryRequest.resourcePath(Olingo2AppImpl.METADATA).build());

        // feed query
        batchParts.add(Olingo2BatchQueryRequest.resourcePath(MANUFACTURERS).build());

        // read
        batchParts.add(Olingo2BatchQueryRequest.resourcePath(TEST_MANUFACTURER).build());

        // read with expand
        final HashMap<String, String> queryParams = new HashMap<String, String>();
        queryParams.put(SystemQueryOption.$expand.toString(), CARS);
        batchParts.add(Olingo2BatchQueryRequest.resourcePath(TEST_MANUFACTURER).queryParams(queryParams).build());

        // create
        final Map<String, Object> data = getEntityData();
        batchParts.add(Olingo2BatchChangeRequest.resourcePath(MANUFACTURERS).
            contentId(TEST_RESOURCE_CONTENT_ID).operation(Operation.CREATE).body(data).build());

        // update
        final Map<String, Object> updateData = new HashMap<String, Object>(data);
        @SuppressWarnings("unchecked")
        Map<String, Object> address = (Map<String, Object>) updateData.get(ADDRESS);
        updateData.put("Name", "MyCarManufacturer Renamed");
        address.put("Street", "Main Street");

        batchParts.add(Olingo2BatchChangeRequest.resourcePath(TEST_RESOURCE).operation(Operation.UPDATE)
            .body(updateData).build());

        // delete
        batchParts.add(Olingo2BatchChangeRequest.resourcePath(TEST_RESOURCE).operation(Operation.DELETE).build());

        final TestOlingo2ResponseHandler<List<Olingo2BatchResponse>> responseHandler =
            new TestOlingo2ResponseHandler<List<Olingo2BatchResponse>>();

        // read to verify delete
        batchParts.add(Olingo2BatchQueryRequest.resourcePath(TEST_CREATE_MANUFACTURER).build());

        olingoApp.batch(edm, batchParts, responseHandler);

        final List<Olingo2BatchResponse> responseParts = responseHandler.await(15, TimeUnit.MINUTES);
        assertEquals("Batch responses expected", 8, responseParts.size());

        assertNotNull(responseParts.get(0).getBody());
        final ODataFeed feed = (ODataFeed) responseParts.get(1).getBody();
        assertNotNull(feed);
        LOG.info("Batch feed:  {}", prettyPrint(feed));

        ODataEntry dataEntry = (ODataEntry) responseParts.get(2).getBody();
        assertNotNull(dataEntry);
        LOG.info("Batch read entry:  {}", prettyPrint(dataEntry));

        dataEntry = (ODataEntry) responseParts.get(3).getBody();
        assertNotNull(dataEntry);
        LOG.info("Batch read entry with expand:  {}", prettyPrint(dataEntry));

        dataEntry = (ODataEntry) responseParts.get(4).getBody();
        assertNotNull(dataEntry);
        LOG.info("Batch create entry:  {}", prettyPrint(dataEntry));

        assertEquals(HttpStatusCodes.NO_CONTENT.getStatusCode(), responseParts.get(5).getStatusCode());
        assertEquals(HttpStatusCodes.NO_CONTENT.getStatusCode(), responseParts.get(6).getStatusCode());

        assertEquals(HttpStatusCodes.NOT_FOUND.getStatusCode(), responseParts.get(7).getStatusCode());
        final Exception exception = (Exception) responseParts.get(7).getBody();
        assertNotNull(exception);
        LOG.info("Batch retrieve deleted entry:  {}", exception);
    }

    private Map<String, Object> getEntityData() {
        Map<String, Object> data = new HashMap<String, Object>();
        data.put(ID_PROPERTY, "123");
        data.put("Name", "MyCarManufacturer");
        data.put(FOUNDED_PROPERTY, new Date());
        Map<String, Object> address = new HashMap<String, Object>();
        address.put("Street", "Main");
        address.put("ZipCode", "42421");
        address.put("City", "Fairy City");
        address.put("Country", "FarFarAway");
        data.put(ADDRESS, address);
        return data;
    }

    private static String prettyPrint(ODataFeed dataFeed) {
        StringBuilder builder = new StringBuilder();
        builder.append("[\n");
        for (ODataEntry entry : dataFeed.getEntries()) {
            builder.append(prettyPrint(entry.getProperties(), 1)).append('\n');
        }
        builder.append("]\n");
        return builder.toString();
    }

    private static String prettyPrint(ODataEntry createdEntry) {
        return prettyPrint(createdEntry.getProperties(), 0);
    }

    private static String prettyPrint(Map<String, Object> properties, int level) {
        StringBuilder b = new StringBuilder();
        Set<Map.Entry<String, Object>> entries = properties.entrySet();

        for (Map.Entry<String, Object> entry : entries) {
            indent(b, level);
            b.append(entry.getKey()).append(": ");
            Object value = entry.getValue();
            if (value instanceof Map) {
                @SuppressWarnings("unchecked")
                final Map<String, Object> objectMap = (Map<String, Object>) value;
                value = prettyPrint(objectMap, level + 1);
                b.append(value).append(NEW_LINE);
            } else if (value instanceof Calendar) {
                Calendar cal = (Calendar) value;
                value = SimpleDateFormat.getInstance().format(cal.getTime());
                b.append(value).append(NEW_LINE);
            } else if (value instanceof ODataDeltaFeed) {
                ODataDeltaFeed feed = (ODataDeltaFeed) value;
                List<ODataEntry> inlineEntries = feed.getEntries();
                b.append("{");
                for (ODataEntry oDataEntry : inlineEntries) {
                    value = prettyPrint(oDataEntry.getProperties(), level + 1);
                    b.append("\n[\n").append(value).append("\n],");
                }
                b.deleteCharAt(b.length() - 1);
                indent(b, level);
                b.append("}\n");
            } else {
                b.append(value).append(NEW_LINE);
            }
        }
        // remove last line break
        b.deleteCharAt(b.length() - 1);
        return b.toString();
    }

    private static void indent(StringBuilder builder, int indentLevel) {
        for (int i = 0; i < indentLevel; i++) {
            builder.append("  ");
        }
    }

    private static final class TestOlingo2ResponseHandler<T> implements Olingo2ResponseHandler<T> {

        private T response;
        private Exception error;
        private CountDownLatch latch = new CountDownLatch(1);

        @Override
        public void onResponse(T response) {
            this.response = response;
            if (LOG.isDebugEnabled()) {
                if (response instanceof ODataFeed) {
                    LOG.debug("Received response: {}", prettyPrint((ODataFeed) response));
                } else if (response instanceof ODataEntry) {
                    LOG.debug("Received response: {}", prettyPrint((ODataEntry) response));
                } else {
                    LOG.debug("Received response: {}", response);
                }
            }
            latch.countDown();
        }

        @Override
        public void onException(Exception ex) {
            error = ex;
            latch.countDown();
        }

        @Override
        public void onCanceled() {
            error = new IllegalStateException("Request Canceled");
            latch.countDown();
        }

        public T await() throws Exception {
            return await(TIMEOUT, TimeUnit.SECONDS);
        }

        public T await(long timeout, TimeUnit unit) throws Exception {
            assertTrue("Timeout waiting for response", latch.await(timeout, unit));
            if (error != null) {
                throw error;
            }
            assertNotNull("Response", response);
            return response;
        }

        public void reset() {
            latch.countDown();
            latch = new CountDownLatch(1);
            response = null;
            error = null;
        }
    }
}
