/*
 * Copyright (C) 2017-2020 HERE Europe B.V.
 *
 * Licensed 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.
 *
 * SPDX-License-Identifier: Apache-2.0
 * License-Filename: LICENSE
 */

package com.here.xyz.hub.task;

import static com.here.xyz.hub.rest.Api.HeaderValues.STREAM_INFO;
import static com.here.xyz.hub.task.FeatureTask.FeatureKey.BBOX;
import static com.here.xyz.hub.task.FeatureTask.FeatureKey.ID;
import static com.here.xyz.hub.task.FeatureTask.FeatureKey.PROPERTIES;
import static com.here.xyz.hub.task.FeatureTask.FeatureKey.TYPE;
import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
import static io.netty.handler.codec.http.HttpResponseStatus.CONFLICT;
import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN;
import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR;
import static io.netty.handler.codec.http.HttpResponseStatus.METHOD_NOT_ALLOWED;
import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.here.xyz.Payload;
import com.here.xyz.XyzSerializable;
import com.here.xyz.events.CountFeaturesEvent;
import com.here.xyz.events.Event;
import com.here.xyz.events.EventNotification;
import com.here.xyz.events.GetFeaturesByBBoxEvent;
import com.here.xyz.events.ModifyFeaturesEvent;
import com.here.xyz.events.ModifySpaceEvent;
import com.here.xyz.hub.Service;
import com.here.xyz.hub.connectors.RpcClient;
import com.here.xyz.hub.connectors.RpcClient.RpcContext;
import com.here.xyz.hub.connectors.models.BinaryResponse;
import com.here.xyz.hub.connectors.models.Connector;
import com.here.xyz.hub.connectors.models.Space;
import com.here.xyz.hub.connectors.models.Space.CacheProfile;
import com.here.xyz.hub.connectors.models.Space.ConnectorType;
import com.here.xyz.hub.connectors.models.Space.ResolvableListenerConnectorRef;
import com.here.xyz.hub.rest.Api;
import com.here.xyz.hub.rest.ApiResponseType;
import com.here.xyz.hub.rest.HttpException;
import com.here.xyz.hub.task.FeatureTask.ConditionalOperation;
import com.here.xyz.hub.task.FeatureTask.DeleteOperation;
import com.here.xyz.hub.task.FeatureTask.ReadQuery;
import com.here.xyz.hub.task.FeatureTask.TileQuery;
import com.here.xyz.hub.task.FeatureTask.TileQuery.TransformationContext;
import com.here.xyz.hub.task.ModifyOp.Entry;
import com.here.xyz.hub.task.ModifyOp.ModifyOpError;
import com.here.xyz.hub.task.TaskPipeline.Callback;
import com.here.xyz.hub.util.geo.MapBoxVectorTileBuilder;
import com.here.xyz.hub.util.geo.MapBoxVectorTileFlattenedBuilder;
import com.here.xyz.models.geojson.WebMercatorTile;
import com.here.xyz.models.geojson.exceptions.InvalidGeometryException;
import com.here.xyz.models.geojson.implementation.Feature;
import com.here.xyz.models.geojson.implementation.FeatureCollection;
import com.here.xyz.models.geojson.implementation.XyzNamespace;
import com.here.xyz.responses.CountResponse;
import com.here.xyz.responses.ErrorResponse;
import com.here.xyz.responses.ModifiedEventResponse;
import com.here.xyz.responses.ModifiedPayloadResponse;
import com.here.xyz.responses.ModifiedResponseResponse;
import com.here.xyz.responses.NotModifiedResponse;
import com.here.xyz.responses.StatisticsResponse;
import com.here.xyz.responses.StatisticsResponse.PropertiesStatistics.Searchable;
import com.here.xyz.responses.SuccessResponse;
import com.here.xyz.responses.XyzResponse;
import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import net.jodah.expiringmap.ExpirationPolicy;
import net.jodah.expiringmap.ExpiringMap;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.Marker;

public class FeatureTaskHandler {

  private static final Logger logger = LogManager.getLogger();

  private static final ExpiringMap<String, Long> countCache = ExpiringMap.builder()
      .maxSize(1024)
      .variableExpiration()
      .expirationPolicy(ExpirationPolicy.CREATED)
      .build();
  private static final byte JSON_VALUE = 1;
  private static final byte BINARY_VALUE = 2;

  /**
   * Sends the event to the connector client and write the response as the responseCollection of the task.
   *
   * @param task the FeatureTask instance
   * @param callback the callback handler
   * @param <T> the type of the FeatureTask
   */
  public static <T extends FeatureTask> void invoke(T task, Callback<T> callback) {
    /**
     * NOTE: The event may only be consumed once. Once it was consumed it should only be referenced in the request-phase. Referencing it in the
     *     response-phase will keep the whole event-data in the memory and could cause many major GCs to because of large request-payloads.
     *
     * @see Task#consumeEvent()
     */
    Event event = task.consumeEvent();
    /*
    In case there is already, nothing has to be done here (happens if the response was set by an earlier process in the task pipeline
    e.g. when having a cache hit)
     */
    if (task.getResponse() != null) {
      callback.call(task);
      return;
    }

    if (!task.storage.active) {
      if (event instanceof ModifySpaceEvent && ((ModifySpaceEvent) event).getOperation() == ModifySpaceEvent.Operation.DELETE) {
        /*
        If connector is inactive we allow space deletions. In this case only the space configuration gets deleted. The
        deactivated connector does not get invoked so the dataset behind stays untouched.
        */
        task.setResponse(new SuccessResponse().withStatus("OK"));
        callback.call(task);
      }
      else {
        //Abort further processing - do not: notifyProcessors, notifyListeners, invoke connector
        callback.exception(new HttpException(BAD_REQUEST, "Related connector is not active: " + task.storage.id));
      }
      return;
    }

    String eventType = event.getClass().getSimpleName();

    //Pre-process the event by executing potentially registered processors
    notifyProcessors(task, eventType, event, preProcessingResult -> {
      if (preProcessingResult.failed() || preProcessingResult.result() instanceof ErrorResponse) {
        handleProcessorFailure(task.getMarker(), preProcessingResult, callback);
        return;
      }
      Event eventToExecute = extractPayloadFromResponse(
          (ModifiedPayloadResponse<? extends ModifiedPayloadResponse>) preProcessingResult.result(), Event.class, event);
      //Call the pre-processed hook if there is one
      if (eventToExecute != event && callProcessedHook(task, eventToExecute, callback)) {
        return;
      }
      EventResponseContext responseContext = new EventResponseContext(eventToExecute);

      //Clone the request-event to be used for request-listener notifications (see below) if there are some to be performed
      //TODO: Only clone the event if necessary (e.g. if listeners or processors are registered)
      //if (task.space.listeners != null && !task.space.listeners.isEmpty()) {
      Event<? extends Event> requestListenerPayload = eventToExecute.copy();
      //}

      //CMEKB-2779 Remove failed entries before calling storage client
      if (eventToExecute instanceof ModifyFeaturesEvent) {
        ((ModifyFeaturesEvent) eventToExecute).setFailed(null);
      }
      //Do the actual storage call
      try {
        setAdditionalEventProps(task, task.storage, eventToExecute);
        final long storageRequestStart = Service.currentTimeMillis();
        responseContext.rpcContext = RpcClient.getInstanceFor(task.storage).execute(task.getMarker(), eventToExecute, storageResult -> {
          addStoragePerformanceInfo(task, Service.currentTimeMillis() - storageRequestStart, responseContext.rpcContext);
          if (storageResult.failed()) {
            handleFailure(task.getMarker(), storageResult.cause(), callback);
            return;
          }
          XyzResponse response = storageResult.result();
          responseContext.enrichResponse(task, response);

          //Do the post-processing here before sending back the response and notifying response-listeners
          notifyProcessors(task, eventType, response, postProcessingResult -> {
            if (postProcessingResult.failed() || postProcessingResult.result() instanceof ErrorResponse) {
              handleProcessorFailure(task.getMarker(), postProcessingResult, callback);
              return;
            }
            XyzResponse responseToSend = extractPayloadFromResponse(
                (ModifiedPayloadResponse<? extends ModifiedPayloadResponse>) postProcessingResult.result(),
                XyzResponse.class, storageResult.result());
            task.setResponse(responseToSend);
            callback.call(task);
            //Send the event's (post-processed) response to potentially registered response-listeners
            notifyListeners(task, eventType, responseToSend);
          });
        });
      }
      catch (IllegalStateException e) {
        callback.exception(new HttpException(BAD_REQUEST, e.getMessage(), e));
        return;
      }
      catch (Exception e) {
        callback.exception(new HttpException(INTERNAL_SERVER_ERROR, "Error executing the storage event.", e));
        return;
      }

      //Update the contentUpdatedAt timestamp to indicate that the data in this space was modified
      if (task instanceof FeatureTask.ConditionalOperation || task instanceof FeatureTask.DeleteOperation) {
        long now = Service.currentTimeMillis();
        if (now - task.space.contentUpdatedAt > Space.CONTENT_UPDATED_AT_INTERVAL_MILLIS) {
          task.space.contentUpdatedAt = Service.currentTimeMillis();
          task.space.volatilityAtLastContentUpdate = task.space.getVolatility();
          Service.spaceConfigClient.store(task.getMarker(), task.space,
              (ar) -> logger.info(task.getMarker(), "Updated contentUpdatedAt for space {}", task.space.getId()));
        }
      }
      //Send event to potentially registered request-listeners
      notifyListeners(task, eventType, requestListenerPayload);
    });
  }

  private static <T extends FeatureTask> void addStoragePerformanceInfo(T task, long storageTime,
      RpcContext rpcContext) {
    String connectorPerformanceValues = "STime=" + storageTime + ";";
    if (rpcContext != null)
      connectorPerformanceValues += "SReqSize=" + rpcContext.getRequestSize() + ";SResSize=" + rpcContext.getResponseSize() + ";";
    addStreamInfo(task, connectorPerformanceValues);
  }

  private static XyzResponse transform(byte[] value) throws JsonProcessingException {
    byte type = value[0];
    byte[] byteValue = Buffer.buffer(value).getBytes(1, value.length);
    switch (type) {
      case JSON_VALUE: {
        return XyzSerializable.deserialize(new String(byteValue));
      }
      case BINARY_VALUE: {
        return new BinaryResponse().withBytes(byteValue);
      }
    }
    return null;
  }

  private static byte[] transform(XyzResponse value) {
    byte[] byteValue;
    byte[] type = new byte[1];
    if (value instanceof BinaryResponse) {
      byteValue = ((BinaryResponse) value).getBytes();
      type[0] = BINARY_VALUE;
    } else {
      byteValue = value.serialize().getBytes();
      type[0] = JSON_VALUE;
    }
    Buffer b = Buffer.buffer(type).appendBytes(byteValue);
    return b.getBytes();
  }

  public static <T extends FeatureTask> void readCache(T task, Callback<T> callback) {
    if (task.getCacheProfile().serviceTTL > 0) {
      String cacheKey = task.getCacheKey();

      //Check the cache
      Service.cacheClient.getBinary(cacheKey, cacheResult -> {
        if (cacheResult == null) {
          //Cache MISS: Just go on in the task pipeline
          addStreamInfo(task, "CH=0;");
          logger.info(task.getMarker(), "Cache MISS for cache key {}", cacheKey);
        } else {
          //Cache HIT: Set the response for the task to the result from the cache so invoke (in the task pipeline) won't have anything to do
          try {
            task.setResponse(transform(cacheResult));
            task.setCacheHit(true);
            addStreamInfo(task, "CH=1;");
            logger.info(task.getMarker(), "Cache HIT for cache key {}", cacheKey);
          } catch (JsonProcessingException e) {
            //Actually, this should never happen as we're controlling how the data is written to the cache, but you never know ;-)
            //Treating an error as a Cache MISS
            logger.info(task.getMarker(), "Cache MISS (as of JSON parse exception) for cache key {} {}", cacheKey, e);
          }
        }
        callback.call(task);
      });
    } else {
      callback.call(task);
    }
  }

  public static <T extends FeatureTask> void writeCache(T task, Callback<T> callback) {
    callback.call(task);
    //From here everything is done asynchronous
    final CacheProfile cacheProfile = task.getCacheProfile();
    //noinspection rawtypes
    XyzResponse response = task.getResponse();
    if (cacheProfile.serviceTTL > 0 && response != null && !task.isCacheHit()
        && !(response instanceof NotModifiedResponse) && !(response instanceof ErrorResponse)) {
      String cacheKey = task.getCacheKey();
      if (cacheKey == null) {
        String npe = "cacheKey is null. Couldn't write cache.";
        logger.error(task.getMarker(), npe);
        throw new NullPointerException(npe);
      }
      logger.debug(task.getMarker(), "Writing entry with cache key {} to cache", cacheKey);
      Service.cacheClient.setBinary(cacheKey, transform(response), cacheProfile.serviceTTL);
    }
  }

  /**
   * @param task the FeatureTask instance
   * @param event The pre-processed event
   * @param callback The callback to be called in case of exception
   * @param <T> the type of the FeatureTask
   * @return Whether to stop the execution as of an exception occurred
   */
  private static <T extends FeatureTask> boolean callProcessedHook(T task, Event event, Callback<T> callback) {
    try {
      task.onPreProcessed(event);
      return false;
    } catch (Exception e) {
      callback.exception(e);
      return true;
    }
  }

  private static <R extends Payload> R extractPayloadFromResponse(ModifiedPayloadResponse<? extends ModifiedPayloadResponse> response,
      Class<R> type,
      R originalPayload) {
    if (response == null) {
      return originalPayload;
    }
    if (response instanceof ModifiedEventResponse) {
      R e = type.cast(((ModifiedEventResponse) response).getEvent());
      return e != null ? e : originalPayload;
    } else if (response instanceof ModifiedResponseResponse) {
      R r = type.cast(((ModifiedResponseResponse) response).getResponse());
      return r != null ? r : originalPayload;
    } else {
      throw new RuntimeException("Unexpected error while extracting the payload from a processor response.");
    }
  }

  private static <T extends FeatureTask> void handleFailure(Marker marker, Throwable cause, Callback<T> callback) {
    if (cause instanceof Exception) {
      callback.exception((Exception) cause);
      return;
    }
    logger.error(marker, "Unexpected error", cause);
    callback.exception(new Exception(cause));
  }

  private static <T extends FeatureTask> void handleProcessorFailure(Marker marker, AsyncResult<XyzResponse> processingResult,
      Callback<T> callback) {
    if (processingResult.failed()) {
      handleFailure(marker, processingResult.cause(), callback);
    } else if (processingResult.result() instanceof ErrorResponse) {
      callback.exception(Api.responseToHttpException(processingResult.result()));
    } else {
      callback.exception(new Exception("Unexpected exception during processor error handling."));
    }
  }

  static <T extends FeatureTask> void setAdditionalEventProps(T task, Connector connector, Event event) {
    event.setMetadata(task.getJwt().metadata);
    if (connector.trusted) {
      event.setTid(task.getJwt().tid);
      event.setAid(task.getJwt().aid);
      event.setJwt(task.getJwt().jwt);
    }
  }

  private static <T extends FeatureTask> void notifyListeners(T task, String eventType, Payload payload) {
    notifyConnectors(task, ConnectorType.LISTENER, eventType, payload, null);
  }

  private static <T extends FeatureTask> void notifyProcessors(T task, String eventType, Payload payload,
      Handler<AsyncResult<XyzResponse>> callback) {
    notifyConnectors(task, ConnectorType.PROCESSOR, eventType, payload, callback);
  }

  private static <T extends FeatureTask> void notifyConnectors(T task, ConnectorType connectorType, String eventType,
      Payload payload, Handler<AsyncResult<XyzResponse>> callback) {
    //Send the event to all registered & matching listeners / processors
    Map<String, List<ResolvableListenerConnectorRef>> connectorMap = task.space.getEventTypeConnectorRefsMap(connectorType);
    if (connectorMap != null && !connectorMap.isEmpty()) {
      String phase = payload instanceof Event ? "request" : "response";
      String notificationEventType = eventType + "." + phase;

      if (connectorMap.containsKey(notificationEventType)) {
        List<ResolvableListenerConnectorRef> connectors = connectorMap.get(notificationEventType);
        if (connectorType == ConnectorType.LISTENER) {
          notifyListeners(task, connectors, notificationEventType, payload);
          return;
        } else if (connectorType == ConnectorType.PROCESSOR) {
          notifyProcessors(task, connectors, notificationEventType, payload, callback);
          return;
        } else {
          throw new RuntimeException("Unsupported connector type.");
        }
      }
    }
    if (callback != null) {
      callback.handle(Future.succeededFuture(null));
    }
  }

  private static <T extends FeatureTask> void notifyListeners(T task, List<ResolvableListenerConnectorRef> listeners,
      String notificationEventType, Payload payload) {
    listeners.forEach(l -> {
      RpcClient client;
      try {
        client = RpcClient.getInstanceFor(l.resolvedConnector);
      } catch (Exception e) {
        logger.warn(task.getMarker(), "Error when trying to get client for remote function (listener) {}.", l.getId(), e);
        return;
      }
      //Send the event (notify the listener)
      client.send(task.getMarker(), createNotification(task, payload, notificationEventType, l));
    });
  }

  private static <T extends FeatureTask> void notifyProcessors(T task, List<ResolvableListenerConnectorRef> processors,
      String notificationEventType, Payload payload, Handler<AsyncResult<XyzResponse>> callback) {

    //For the first call we're mocking a ModifiedPayloadResponse as if it was coming from a previous processor
    ModifiedPayloadResponse initialResponse = payload instanceof Event ?
        new ModifiedEventResponse().withEvent((Event<? extends Event>) payload) : new ModifiedResponseResponse().withResponse(payload);
    CompletableFuture<XyzResponse> initialFuture = CompletableFuture.completedFuture(initialResponse);
    final List<FeatureCollection.ModificationFailure> failed = new LinkedList<>();

    CompletableFuture<XyzResponse> processedResult = processors.stream().reduce(initialFuture, (prevFuture, processor) -> {
      CompletableFuture<XyzResponse> nextFuture = new CompletableFuture<>();

      prevFuture
          //In case of error fail all following stages directly as well
          .exceptionally(e -> {
            nextFuture.completeExceptionally(e);
            return null;
          })
          //Execute the processor with the result of the previous processor and inform following stages about the outcome
          .thenAccept(result -> {
            if (result == null) {
              return; //Happens in case of exception. Then the exceptionally handler already took over to inform the following stages.
            }

            Payload payloadToSend;

            if (result instanceof ErrorResponse) {
              //Handle well-thrown connector error by bubbling it through all stages till the end
              nextFuture.complete(result);
              return;
            } else if (result instanceof ModifiedEventResponse) {
              payloadToSend = ((ModifiedEventResponse) result).getEvent();
              // CMEKB-2779 Store ModificationFailures outside of the event
              if (payloadToSend instanceof ModifyFeaturesEvent) {
                ModifyFeaturesEvent modifyFeaturesEvent = (ModifyFeaturesEvent) payloadToSend;
                if (modifyFeaturesEvent.getFailed() != null) {
                  failed.addAll(modifyFeaturesEvent.getFailed());
                  modifyFeaturesEvent.setFailed(null);
                }
              }
            } else {
              payloadToSend = ((ModifiedResponseResponse) result).getResponse();
            }

            //Execute the processor with the event / response payload (do pre-processing / post-processing)
            executeProcessor(task, processor, notificationEventType, payloadToSend)
                .exceptionally(ex -> {
                  nextFuture.completeExceptionally(ex);
                  return null;
                })
                .thenAccept(processed -> {
                  if (processed != null) {
                    nextFuture.complete(processed);
                  }
                });
          });

      //Return the future for the next processor
      return nextFuture;
    }, (result1, result2) -> result1 == null ? result2 : result1);

    //Finally report the result of the last stage
    processedResult
        .exceptionally(ex -> {
          callback.handle(Future.failedFuture(ex));
          return null;
        })
        .thenAccept(processed -> {
          if (processed != null) {
            //CMEKB-2779 Get ModificationFailures from last processor and set the collected list
            if (processed instanceof ModifiedEventResponse &&
                ((ModifiedEventResponse) processed).getEvent() instanceof ModifyFeaturesEvent) {
              ModifyFeaturesEvent event = (ModifyFeaturesEvent) ((ModifiedEventResponse) processed).getEvent();
              if (event.getFailed() != null) {
                failed.addAll(event.getFailed());
              }
              event.setFailed(failed.isEmpty() ? null : failed);
            }
            callback.handle(Future.succeededFuture(processed));
          }
        });
  }

  private static <T extends FeatureTask> CompletableFuture<XyzResponse> executeProcessor(T task,
      ResolvableListenerConnectorRef p, String notificationEventType, Payload payload) {
    CompletableFuture<XyzResponse> f = new CompletableFuture<>();

    RpcClient client;
    try {
      client = RpcClient.getInstanceFor(p.resolvedConnector);
    } catch (Exception e) {
      f.completeExceptionally(new Exception("Error when trying to get client for remote function (processor) " + p.getId() + ".", e));
      return f;
    }
    //Execute the processor with the event / response payload (do pre-processing / post-processing)
    client.execute(task.getMarker(), createNotification(task, payload, notificationEventType, p), ar -> {
      if (ar.failed()) {
        f.completeExceptionally(ar.cause());
      } else {
        f.complete(ar.result());
      }
    });
    return f;
  }

  private static <T extends FeatureTask> EventNotification createNotification(T task, Payload payload, String notificationEventType,
      ResolvableListenerConnectorRef l) {
    //Create the EventNotification-event
    EventNotification event = new EventNotification()
        .withParams(l.getParams())
        .withEventType(notificationEventType)
        .withEvent(payload)
        .withSpace(task.space.getId())
        .withStreamId(task.getMarker().getName());
    setAdditionalEventProps(task, l.resolvedConnector, event);
    return event;
  }

  /**
   * Resolves the space, its storage and its listeners.
   */
  static <X extends FeatureTask> void resolveSpace(final X task, final Callback<X> callback) {
    try {
      //FIXME: Can be removed once the Space events are handled by the SpaceTaskHandler (refactoring pending ...)
      if (task.space != null) { //If the space is already given we don't need to retrieve it
        onSpaceResolved(task, callback);
        return;
      }

      //Load the space definition.
      Service.spaceConfigClient.get(task.getMarker(), task.getEvent().getSpace(), arSpace -> {
        if (arSpace.failed()) {
          logger
              .info(task.getMarker(), "Unable to load the space definition for space '{}' {}", task.getEvent().getSpace(), arSpace.cause());
          callback.exception(new HttpException(INTERNAL_SERVER_ERROR, "Unable to load the space definition", arSpace.cause()));
          return;
        }
        task.space = arSpace.result();
        if (task.space != null) {
          task.getEvent().setParams(task.space.getStorage().getParams());
        }
        onSpaceResolved(task, callback);
      });
    } catch (Exception e) {
      callback.exception(new HttpException(INTERNAL_SERVER_ERROR, "Unable to load the space definition", e));
    }
  }

  private static <X extends FeatureTask> void onSpaceResolved(final X task, final Callback<X> callback) {
    if (task.space == null) {
      callback.exception(new HttpException(NOT_FOUND, "The space with this ID does not exist."));
      return;
    }
    logger.debug(task.getMarker(), "Given space configuration is: {}", Json.encode(task.space));

    final String storageId = task.space.getStorage().getId();
    addStreamInfo(task, "SID=" + storageId + ";");
    Space.resolveConnector(task.getMarker(), storageId, (arStorage) -> {
      if (arStorage.failed()) {
        callback.exception(new InvalidStorageException("Unable to load the definition for this storage."));
        return;
      }

      task.storage = arStorage.result();

      try {
        //Also resolve all listeners & processors
        CompletableFuture.allOf(
            resolveConnectors(task.getMarker(), task.space, ConnectorType.LISTENER),
            resolveConnectors(task.getMarker(), task.space, ConnectorType.PROCESSOR)
        ).thenRun(() -> {
          //All listener & processor refs have been resolved now
          callback.call(task);
        });
      } catch (Exception e) {
        logger.error(task.getMarker(), "The listeners for this space cannot be initialized", e);
        callback.exception(new HttpException(INTERNAL_SERVER_ERROR, "The listeners for this space cannot be initialized"));
      }
    });
  }

  private static CompletableFuture<Void> resolveConnectors(Marker marker, final Space space, final ConnectorType connectorType) {
    if (space == null || connectorType == null) {
      return CompletableFuture.completedFuture(null);
    }

    final Map<String, List<Space.ListenerConnectorRef>> connectorRefs = space.getConnectorRefsMap(connectorType);

    if (connectorRefs == null || connectorRefs.isEmpty()) {
      return CompletableFuture.completedFuture(null);
    }

    CompletableFuture<Void> future = new CompletableFuture<>();
    List<CompletableFuture<Void>> futures = new ArrayList<>();
    for (Map.Entry<String, List<Space.ListenerConnectorRef>> entry : connectorRefs.entrySet()) {
      if (entry.getValue() != null && !entry.getValue().isEmpty()) {
        ListIterator<Space.ListenerConnectorRef> i = entry.getValue().listIterator();
        while (i.hasNext()) {
          Space.ListenerConnectorRef cR = i.next();
          CompletableFuture<Void> f = new CompletableFuture<>();
          Space.resolveConnector(marker, entry.getKey(), arListener -> {
            final Connector c = arListener.result();
            ResolvableListenerConnectorRef rCR = new ResolvableListenerConnectorRef();
            rCR.setId(entry.getKey());
            rCR.setParams(cR.getParams());
            rCR.setOrder(cR.getOrder());
            rCR.setEventTypes(cR.getEventTypes());
            rCR.resolvedConnector = c;
            //If no event types have been defined in the connectorRef we use the defaultEventTypes from the resolved connector config
            if ((rCR.getEventTypes() == null || rCR.getEventTypes().isEmpty()) && c.defaultEventTypes != null && !c.defaultEventTypes
                .isEmpty()) {
              rCR.setEventTypes(new ArrayList<>(c.defaultEventTypes));
            }
            // replace ListenerConnectorRef with ResolvableListenerConnectorRef
            i.set(rCR);
            f.complete(null);
          });
          futures.add(f);
        }
      }
    }

    //When all listeners have been resolved we can complete the returned future.
    CompletableFuture
        .allOf(futures.toArray(new CompletableFuture[0]))
        .thenRun(() -> future.complete(null));

    return future;
  }

  static void preprocessConditionalOp(ConditionalOperation task, Callback<ConditionalOperation> callback) throws Exception {
    try {
      task.getEvent().setEnableUUID(task.space.isEnableUUID());
      // Ensure that the ID is a string or null and check for duplicate IDs
      Map<String, Boolean> ids = new HashMap<>();
      for (Entry<Feature> entry : task.modifyOp.entries) {
        final Object objId = entry.input.get(ID);
        String id = (objId instanceof String || objId instanceof Number) ? String.valueOf(objId) : null;
        if (task.prefixId != null) { // Generate IDs here, if a prefixId is required. Add the prefix otherwise.
          id = task.prefixId + ((id == null) ? RandomStringUtils.randomAlphanumeric(16) : id);
        }
        entry.input.put(ID, id);

        if (id != null) { 
          // Minimum length of id should be 1
          if (id.length() < 1) {
            logger.info(task.getMarker(), "Minimum length of object id should be 1.");
            callback.exception(new HttpException(BAD_REQUEST, "Minimum length of object id should be 1."));
            return;
          }
          // Test for duplicate IDs
          if (ids.containsKey(id)) {
            logger.info(task.getMarker(), "Objects with the same ID {} are included in the request.", id);
            callback.exception(new HttpException(BAD_REQUEST, "Objects with the same ID " + id + " is included in the request."));
            return;
          }
          ids.put(id, true);
        }

        entry.input.putIfAbsent(TYPE, "Feature");

        // bbox is a dynamically calculated property
        entry.input.remove(BBOX);

        // Add the XYZ namespace if it is not set yet.
        entry.input.putIfAbsent(PROPERTIES, new HashMap<String, Object>());
        @SuppressWarnings("unchecked") final Map<String, Object> properties = (Map<String, Object>) entry.input.get("properties");
        properties.putIfAbsent(XyzNamespace.XYZ_NAMESPACE, new HashMap<String, Object>());
      }
    } catch (Exception e) {
      logger.error(task.getMarker(), e.getMessage(), e);
      callback.exception(new HttpException(BAD_REQUEST, "Unable to process the request input."));
      return;
    }
    callback.call(task);
  }

  static void processConditionalOp(ConditionalOperation task, Callback<ConditionalOperation> callback) throws Exception {
    try {
      task.modifyOp.process();
      final List<Feature> insert = new ArrayList<>();
      final List<Feature> update = new ArrayList<>();
      final Map<String, String> delete = new HashMap<>();
      List<FeatureCollection.ModificationFailure> fails = new ArrayList<>();

      long now = Service.currentTimeMillis();

      for (int i = 0; i < task.modifyOp.entries.size(); i++) {
        final Entry<Feature> entry = task.modifyOp.entries.get(i);

        if(entry.exception != null){
          fails.add(new FeatureCollection.ModificationFailure()
                  .withMessage(entry.exception.getMessage())
                  .withId(entry.head.getId()));
          continue;
        }

        if (!entry.isModified) {
          task.hasNonModified = true;
          /** Entry does not exist - remove it to prevent null references */
          if(entry.head == null && entry.base == null)
            task.modifyOp.entries.remove(entry);
          continue;
        }

        final Feature result = entry.result;

        // Insert or update
        if (result != null) {

          try {
            result.validateGeometry();
          } catch (InvalidGeometryException e) {
            logger.info(task.getMarker(), "Invalid geometry found in feature: {}", result, e);
            throw new HttpException(BAD_REQUEST, e.getMessage() + ". Feature: \n" + Json.encode(entry.input));
          }

          final XyzNamespace nsXyz = result.getProperties().getXyzNamespace();

          // Set the space ID
          nsXyz.setSpace(task.space.getId());

          // Normalize the tags
          final List<String> tags = nsXyz.getTags();
          if (tags != null) {
            XyzNamespace.normalizeTagsOfFeature(result);
          } else {
            nsXyz.setTags(new ArrayList<>());
          }

          nsXyz.withInputPosition((long) i);

          // INSERT
          if (entry.head == null) {
            // Timestamps
            nsXyz.setCreatedAt(now);
            nsXyz.setUpdatedAt(now);

            // UUID
            if (task.space.isEnableUUID()) {
              nsXyz.setUuid(java.util.UUID.randomUUID().toString());
            }
            insert.add(result);
          }
          // UPDATE
          else {
            // Timestamps
            nsXyz.setCreatedAt(entry.head.getProperties().getXyzNamespace().getCreatedAt());
            nsXyz.setUpdatedAt(now);

            // UUID
            if (task.space.isEnableUUID()) {
              nsXyz.setUuid(java.util.UUID.randomUUID().toString());
              nsXyz.setPuuid(entry.head.getProperties().getXyzNamespace().getUuid());
              // If the user was updating an older version, set it under the merge uuid
              if (!entry.base.equals(entry.head)) {
                nsXyz.setMuuid(entry.base.getProperties().getXyzNamespace().getUuid());
              }
            }
            update.add(result);
          }
        }

        // DELETE
        else if (entry.head != null) {
          delete.put(entry.head.getId(), entry.inputUUID);
        }
      }

      task.getEvent().setInsertFeatures(insert);
      task.getEvent().setUpdateFeatures(update);
      task.getEvent().setDeleteFeatures(delete);
      task.getEvent().setFailed(fails);

      // In case nothing was changed, set the response directly to skip calling the storage connector.
      if (insert.size() == 0 && update.size() == 0 && delete.size() == 0) {
        FeatureCollection fc = new FeatureCollection();
        if( task.hasNonModified ){
          task.modifyOp.entries.stream().filter(e -> !e.isModified).forEach(e -> {
            try {
              if(e.result != null)
                fc.getFeatures().add(e.result);
            } catch (JsonProcessingException ignored) {}
          });
        }
        if(fails.size() > 0)
          fc.setFailed(fails);
        task.setResponse(fc);
      }

      callback.call(task);
    } catch (ModifyOpError e) {
      logger.info(task.getMarker(), "ConditionalOperationError: {}", e.getMessage(), e);
      throw new HttpException(CONFLICT, e.getMessage());
    }
  }

  static void updateTags(FeatureTask.ConditionalOperation task, Callback<FeatureTask.ConditionalOperation> callback) {
    if ((task.addTags == null || task.addTags.size() == 0) && (task.removeTags == null || task.removeTags.size() == 0)) {
      callback.call(task);
      return;
    }

    for (Entry<Feature> entry : task.modifyOp.entries) {
      // For existing objects: if the input does not contain the tags, copy them from the edited state.
      final Map<String, Object> nsXyz = new JsonObject(entry.input).getJsonObject("properties").getJsonObject(XyzNamespace.XYZ_NAMESPACE)
          .getMap();
      if (!(nsXyz.get("tags") instanceof List)) {
        ArrayList<String> inputTags = new ArrayList<>();
        if (entry.base != null && entry.base.getProperties().getXyzNamespace().getTags() != null) {
          inputTags.addAll(entry.base.getProperties().getXyzNamespace().getTags());
        }
        nsXyz.put("tags", inputTags);
      }
      final List<String> tags = (List<String>) nsXyz.get("tags");
      if (task.addTags != null) {
        task.addTags.forEach(tag -> {
          if (!tags.contains(tag)) {
            tags.add(tag);
          }
        });
      }
      if (task.removeTags != null) {
        task.removeTags.forEach(tags::remove);
      }
    }

    callback.call(task);
  }

  static <X extends FeatureTask<?, X>> void enforceUsageQuotas(X task, Callback<X> callback) {
    final long maxFeaturesPerSpace = task.getJwt().limits != null ? task.getJwt().limits.maxFeaturesPerSpace : -1;
    if (maxFeaturesPerSpace <= 0) {
      callback.call(task);
      return;
    }

    Long cachedCount = countCache.get(task.space.getId());
    if (cachedCount != null) {
      checkFeaturesPerSpaceQuota(task, callback, maxFeaturesPerSpace, cachedCount);
      return;
    }

    getCountForSpace(task, countResult -> {
      if (countResult.failed()) {
        callback.exception(new Exception(countResult.cause()));
        return;
      }
      // Check the quota
      Long count = countResult.result();
      long ttl = (maxFeaturesPerSpace - count > 100_000) ? 60 : 10;
      countCache.put(task.space.getId(), count, ttl, TimeUnit.SECONDS);
      checkFeaturesPerSpaceQuota(task, callback, maxFeaturesPerSpace, count);
    });
  }

  private static <X extends FeatureTask<?, X>> void checkFeaturesPerSpaceQuota(X task, Callback<X> callback,
      long maxFeaturesPerSpace, Long count) {
    try {
      ModifyFeaturesEvent modifyEvent = (ModifyFeaturesEvent) task.getEvent();
      if (modifyEvent != null) {
        final List<Feature> insertFeaturesList = modifyEvent.getInsertFeatures();
        final int insertFeaturesSize = insertFeaturesList == null ? 0 : insertFeaturesList.size();
        final Map<String, String> deleteFeaturesMap = modifyEvent.getDeleteFeatures();
        final int deleteFeaturesSize = deleteFeaturesMap == null ? 0 : deleteFeaturesMap.size();
        final int featuresDelta = insertFeaturesSize - deleteFeaturesSize;
        if (featuresDelta > 0 && count + featuresDelta > maxFeaturesPerSpace) {
          callback.exception(new HttpException(FORBIDDEN,
              "The maximum number of " + maxFeaturesPerSpace + " features per space was reached. The space contains " + count
                  + " features and cannot store " + featuresDelta + " more features."));
          return;
        }
      }
      callback.call(task);
    } catch (Exception e) {
      callback.exception(e);
    }
  }

  private static <X extends FeatureTask<?, X>>void getCountForSpace(X task, Handler<AsyncResult<Long>> handler) {
    final CountFeaturesEvent countEvent = new CountFeaturesEvent();
    countEvent.setSpace(task.getEvent().getSpace());
    countEvent.setParams(task.getEvent().getParams());

    try {
      RpcClient.getInstanceFor(task.storage)
          .execute(task.getMarker(), countEvent, (AsyncResult<XyzResponse> eventHandler) -> {
            if (eventHandler.failed()) {
              handler.handle(Future.failedFuture((eventHandler.cause())));
              return;
            }
            Long count;
            final XyzResponse response = eventHandler.result();
            if (response instanceof CountResponse) {
              count = ((CountResponse) response).getCount();
            } else if (response instanceof FeatureCollection) {
              count = ((FeatureCollection) response).getCount();
            } else {
              handler.handle(Future.failedFuture(Api.responseToHttpException(response)));
              return;
            }
            handler.handle(Future.succeededFuture(count));
          });
    } catch (Exception e) {
      handler.handle(Future.failedFuture((e)));
    }
  }

  static void transformResponse(TileQuery task, Callback<TileQuery> callback) {
    if ((ApiResponseType.MVT != task.responseType && ApiResponseType.MVT_FLATTENED != task.responseType)
        || !(task.getResponse() instanceof FeatureCollection)) {
      callback.call(task);
      return;
    }

    BinaryResponse binaryResponse = new BinaryResponse();
    binaryResponse.setEtag(task.getResponse().getEtag());
    TransformationContext tc = task.transformationContext;

    //The mvt transformation is not executed, if the source feature collection is the same.
    if (!task.etagMatches()) {
      try {
        byte[] mvt;
        if (ApiResponseType.MVT == task.responseType) {
          mvt = new MapBoxVectorTileBuilder()
              .build(WebMercatorTile.forWeb(tc.level, tc.x, tc.y), tc.margin, task.space.getId(),
                  ((FeatureCollection) task.getResponse()).getFeatures());
        } else {
          mvt = new MapBoxVectorTileFlattenedBuilder()
              .build(WebMercatorTile.forWeb(tc.level, tc.x, tc.y), tc.margin, task.space.getId(),
                  ((FeatureCollection) task.getResponse()).getFeatures());
        }
        binaryResponse.setBytes(mvt);
      } catch (Exception e) {
        logger.info(task.getMarker(), "Exception while transforming the response.", e);
        callback.exception(new HttpException(INTERNAL_SERVER_ERROR, "Error while transforming the response."));
        return;
      }
    }

    task.setResponse(binaryResponse);

    callback.call(task);
  }

  private static <T extends FeatureTask> void addStreamInfo(T task, String streamInfoValues) {
    if (task.context.response().headers().contains(STREAM_INFO))
      streamInfoValues = task.context.response().headers().get(STREAM_INFO) + streamInfoValues;

    task.context.response().putHeader(STREAM_INFO, streamInfoValues);
  }

  public static <X extends FeatureTask<?, X>> void validate(X task, Callback<X> callback) {
    if (task instanceof ReadQuery && ((ReadQuery) task).hasPropertyQuery()
        && !task.storage.capabilities.propertySearch) {
      callback.exception(new HttpException(BAD_REQUEST, "Property search queries are not supported by storage connector "
          + "\"" + task.storage.id + "\"."));
      return;
    }

    if (task.getEvent() instanceof GetFeaturesByBBoxEvent) {
      GetFeaturesByBBoxEvent event = (GetFeaturesByBBoxEvent) task.getEvent();
      String clusteringType = event.getClusteringType();
      if (clusteringType != null && (task.storage.capabilities.clusteringTypes == null
          || !task.storage.capabilities.clusteringTypes.contains(clusteringType))) {
        callback.exception(new HttpException(BAD_REQUEST, "Clustering of type \"" + clusteringType + "\" is not"
            + "supported by storage connector \"" + task.storage.id + "\"."));
      }
    }
    callback.call(task);
  }

  static <X extends FeatureTask<?, X>> void convertResponse(X task, Callback<X> callback) throws JsonProcessingException {
    if (task instanceof FeatureTask.GetStatistics) {
      if (task.getResponse() instanceof StatisticsResponse) {
        //Ensure the StatisticsResponse is correctly set-up
        StatisticsResponse response = (StatisticsResponse) task.getResponse();
        defineGlobalSearchableField(response, task);
      }
    } else if (task instanceof FeatureTask.IdsQuery) {
      //Ensure to return a FeatureCollection when there are multiple features in the response (could happen e.g. for a virtual-space)
      if (task.getResponse() instanceof FeatureCollection && ((FeatureCollection) task.getResponse()).getFeatures() != null
          && ((FeatureCollection) task.getResponse()).getFeatures().size() > 1) {
        task.responseType = ApiResponseType.FEATURE_COLLECTION;
      }
    }
    callback.call(task);
  }

  private static void defineGlobalSearchableField(StatisticsResponse response, FeatureTask task) {
    if (!task.storage.capabilities.propertySearch) {
      response.getProperties().setSearchable(Searchable.NONE);
    }

    // updates the searchable flag for each property in case of ALL or NONE
    final Searchable searchable = response.getProperties().getSearchable();
    if (searchable != null && searchable != Searchable.PARTIAL) {
      if (response.getProperties().getValue() != null) {
        response.getProperties().getValue().forEach(c -> c.setSearchable(searchable == Searchable.ALL));
      }
    }
  }

  static <X extends FeatureTask<?, X>> void checkPreconditions(X task, Callback<X> callback) throws HttpException {
    if (task.space.isReadOnly() && (task instanceof ConditionalOperation || task instanceof DeleteOperation)) {
      throw new HttpException(METHOD_NOT_ALLOWED,
          "The method is not allowed, because the space is marked as read-only. Update the space definition to enable editing of features.");
    }
    callback.call(task);
  }

  public static class InvalidStorageException extends Exception {

    InvalidStorageException(String msg) {
      super(msg);
    }
  }

  /**
   * Extracts specific information out of the event which should survive in memory until the response phase.
   */
  private static class EventResponseContext {

    List<FeatureCollection.ModificationFailure> failedModifications;
    Class<? extends Event> eventType;
    RpcContext rpcContext;

    EventResponseContext(Event event) {
      eventType = event.getClass();
      if (event instanceof ModifyFeaturesEvent) {
        failedModifications = ((ModifyFeaturesEvent) event).getFailed();
      }
    }

    <T extends FeatureTask, R extends XyzResponse> void enrichResponse(T task, R response) {
      if (task instanceof ConditionalOperation && response instanceof FeatureCollection && ((ConditionalOperation) task).hasNonModified) {
        ((ConditionalOperation) task).modifyOp.entries.stream().filter(e -> !e.isModified).forEach(e -> {
          try {
            ((FeatureCollection) response).getFeatures().add(e.result);
          } catch (JsonProcessingException ignored) {}
        });
      }
      if (eventType.isAssignableFrom(ModifyFeaturesEvent.class) && failedModifications != null && !failedModifications.isEmpty()) {
        //Copy over the failed modifications information to the response
        List<FeatureCollection.ModificationFailure> failed = ((FeatureCollection) response).getFailed();
        if (failed == null) {
          ((FeatureCollection) response).setFailed(failedModifications);
        } else {
          failed.addAll(failedModifications);
        }
      }
    }
  }
}