/*
 * Copyright 2014-2017 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.
 */
package org.hawkular.metrics.api.jaxrs.handler;

import static javax.ws.rs.core.MediaType.APPLICATION_JSON;

import static org.hawkular.metrics.api.jaxrs.util.ApiUtils.badRequest;
import static org.hawkular.metrics.api.jaxrs.util.ApiUtils.serverError;
import static org.hawkular.metrics.model.MetricType.GAUGE;
import static org.hawkular.metrics.model.MetricType.GAUGE_RATE;
import static org.hawkular.metrics.model.MetricType.UNDEFINED;

import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.regex.PatternSyntaxException;

import javax.enterprise.context.ApplicationScoped;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.POST;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.container.AsyncResponse;
import javax.ws.rs.container.Suspended;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;

import org.hawkular.metrics.api.jaxrs.AggregatedStatsQueryRequest;
import org.hawkular.metrics.api.jaxrs.QueryRequest;
import org.hawkular.metrics.api.jaxrs.handler.observer.MetricCreatedObserver;
import org.hawkular.metrics.api.jaxrs.handler.observer.ResultSetObserver;
import org.hawkular.metrics.api.jaxrs.handler.template.IMetricsHandler;
import org.hawkular.metrics.api.jaxrs.param.TimeAndBucketParams;
import org.hawkular.metrics.api.jaxrs.param.TimeAndSortParams;
import org.hawkular.metrics.api.jaxrs.util.ApiUtils;
import org.hawkular.metrics.api.jaxrs.util.Logged;
import org.hawkular.metrics.core.service.Functions;
import org.hawkular.metrics.core.service.Order;
import org.hawkular.metrics.core.service.transformers.MinMaxTimestampTransformer;
import org.hawkular.metrics.model.ApiError;
import org.hawkular.metrics.model.DataPoint;
import org.hawkular.metrics.model.Metric;
import org.hawkular.metrics.model.MetricId;
import org.hawkular.metrics.model.MetricType;
import org.hawkular.metrics.model.NumericBucketPoint;
import org.hawkular.metrics.model.Percentile;
import org.hawkular.metrics.model.TaggedBucketPoint;
import org.hawkular.metrics.model.exception.RuntimeApiError;
import org.hawkular.metrics.model.param.BucketConfig;
import org.hawkular.metrics.model.param.Duration;
import org.hawkular.metrics.model.param.Percentiles;
import org.hawkular.metrics.model.param.TagNames;
import org.hawkular.metrics.model.param.Tags;
import org.hawkular.metrics.model.param.TimeRange;
import org.jboss.resteasy.annotations.GZIP;

import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import io.swagger.annotations.ApiResponse;
import io.swagger.annotations.ApiResponses;
import rx.Observable;
import rx.schedulers.Schedulers;

/**
 * @author Stefan Negrea
 *
 */
@Path("/gauges")
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
@GZIP
@Api(tags = "Gauge")
@ApplicationScoped
@Logged
public class GaugeHandler extends MetricsServiceHandler implements IMetricsHandler<Double> {

    @POST
    @Path("/")
    @ApiOperation(value = "Create gauge metric.", notes = "Clients are not required to explicitly create "
            + "a metric before storing data. Doing so however allows clients to prevent naming collisions and to "
            + "specify tags and data retention.")
    @ApiResponses(value = {
            @ApiResponse(code = 201, message = "Metric created successfully"),
            @ApiResponse(code = 400, message = "Missing or invalid payload", response = ApiError.class),
            @ApiResponse(code = 409, message = "Gauge metric with given id already exists",
                    response = ApiError.class),
            @ApiResponse(code = 500, message = "Metric creation failed due to an unexpected error",
                    response = ApiError.class)
    })
    public void createMetric(
            @Suspended final AsyncResponse asyncResponse,
            @ApiParam(required = true) Metric<Double> metric,
            @ApiParam(value = "Overwrite previously created metric configuration if it exists. "
                    + "Only data retention and tags are overwriten; existing data points are unnafected. "
                    + "Defaults to false."
            ) @DefaultValue("false") @QueryParam("overwrite") Boolean overwrite,
            @Context UriInfo uriInfo
    ) {
        if (metric.getType() != null && UNDEFINED != metric.getType() && GAUGE != metric.getType()) {
            asyncResponse.resume(badRequest(new ApiError("Metric type does not match " + GAUGE.getText())));
        }
        metric = new Metric<>(new MetricId<>(getTenant(), GAUGE, metric.getId()), metric.getTags(),
                metric.getDataRetention());
        URI location = uriInfo.getBaseUriBuilder().path("/gauges/{id}").build(metric.getMetricId().getName());
        metricsService.createMetric(metric, overwrite).subscribe(new MetricCreatedObserver(asyncResponse, location));
    }

    @GET
    @Path("/")
    @ApiOperation(value = "Find tenant's metric definitions.",
                    notes = "Does not include any metric values. ",
                    response = Metric.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully retrieved at least one metric definition."),
            @ApiResponse(code = 204, message = "No metrics found."),
            @ApiResponse(code = 400, message = "Invalid type parameter type.", response = ApiError.class),
            @ApiResponse(code = 500, message = "Failed to retrieve metrics due to unexpected error.",
                    response = ApiError.class)
    })
    public void getMetrics(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(value = "List of tags filters") @QueryParam("tags") String tags,
            @ApiParam(value = "Fetch min and max timestamps of available datapoints") @DefaultValue("false")
            @QueryParam("timestamps") Boolean fetchTimestamps) {

        Observable<Metric<Double>> metricObservable = null;
        if (tags != null) {
            metricObservable = metricsService.findMetricIdentifiersWithFilters(getTenant(), GAUGE, tags)
                    .flatMap(metricsService::findMetric);
        } else {
            metricObservable = metricsService.findMetrics(getTenant(), GAUGE);
        }

        if(fetchTimestamps) {
            metricObservable = metricObservable
                    .compose(new MinMaxTimestampTransformer<>(metricsService));
        }

        metricObservable
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> {
                    if (t instanceof PatternSyntaxException) {
                        asyncResponse.resume(badRequest(t));
                    } else {
                        asyncResponse.resume(serverError(t));
                    }
                });
    }

    @GET
    @Path("/{id}")
    @ApiOperation(value = "Retrieve single metric definition.", response = Metric.class)
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Metric's definition was successfully retrieved."),
            @ApiResponse(code = 204, message = "Query was successful, but no metrics definition is set."),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric's definition.",
                         response = ApiError.class) })
    public void getMetric(@Suspended final AsyncResponse asyncResponse, @PathParam("id") String id) {
        metricsService.findMetric(new MetricId<>(getTenant(), GAUGE, id))
                .compose(new MinMaxTimestampTransformer<>(metricsService))
                .map(metric -> Response.ok(metric).build())
                .switchIfEmpty(Observable.just(ApiUtils.noContent()))
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.serverError(t)));
    }

    @DELETE
    @Path("/{id}")
    @ApiOperation(value = "Deletes the metric and associated uncompressed data points, and updates internal indexes."
            + " Note: compressed data will not be deleted immediately. It is deleted as part of the normal"
            + " data expiration as defined by the data retention settings. Consequently, compressed data will"
            + " be accessible until it automatically expires.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Metric deletion was successful."),
            @ApiResponse(code = 500, message = "Unexpected error occurred trying to delete the metric.")
    })
    public void deleteMetric(@Suspended AsyncResponse asyncResponse, @PathParam("id") String id) {
        MetricId<Double> metric = new MetricId<>(getTenant(), GAUGE, id);
        metricsService.deleteMetric(metric).subscribe(new ResultSetObserver(asyncResponse));
    }

    @GET
    @Path("/tags/{tags}")
    @ApiOperation(value = "Retrieve gauge type's tag values", response = Map.class)
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Tags successfully retrieved."),
            @ApiResponse(code = 204, message = "No matching tags were found"),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching tags.",
                    response = ApiError.class)
    })
    public void getTags(@Suspended final AsyncResponse asyncResponse,
                        @ApiParam("Tag query") @PathParam("tags") Tags tags) {
        metricsService.getTagValues(getTenant(), GAUGE, tags.getTags())
                .map(ApiUtils::mapToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.serverError(t)));
    }

    @GET
    @Path("/{id}/tags")
    @ApiOperation(value = "Retrieve tags associated with the metric definition.", response = Map.class)
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Metric's tags were successfully retrieved."),
            @ApiResponse(code = 204, message = "Query was successful, but no metrics were found."),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric's tags.",
                response = ApiError.class) })
    public void getMetricTags(
            @Suspended final AsyncResponse asyncResponse,
            @PathParam("id") String id
    ) {
        metricsService.getMetricTags(new MetricId<>(getTenant(), GAUGE, id))
                .map(ApiUtils::mapToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.serverError(t)));
    }

    @PUT
    @Path("/{id}/tags")
    @ApiOperation(value = "Update tags associated with the metric definition.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Metric's tags were successfully updated."),
            @ApiResponse(code = 500, message = "Unexpected error occurred while updating metric's tags.",
                response = ApiError.class) })
    public void updateMetricTags(
            @Suspended final AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(required = true) Map<String, String> tags
    ) {
        Metric<Double> metric = new Metric<>(new MetricId<>(getTenant(), GAUGE, id));
        metricsService.addTags(metric, tags).subscribe(new ResultSetObserver(asyncResponse));
    }

    @DELETE
    @Path("/{id}/tags/{tags}")
    @ApiOperation(value = "Delete tags associated with the metric definition.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Metric's tags were successfully deleted."),
            @ApiResponse(code = 400, message = "Invalid tags", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while trying to delete metric's tags.",
                response = ApiError.class) })
    public void deleteMetricTags(
            @Suspended final AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Tag names", allowableValues = "Comma-separated list of tag names")
            @PathParam("tags") TagNames tags
    ) {
        Metric<Double> metric = new Metric<>(new MetricId<>(getTenant(), GAUGE, id));
        metricsService.deleteTags(metric, tags.getNames()).subscribe(new ResultSetObserver(asyncResponse));
    }

    @POST
    @Path("/{id}/raw")
    @ApiOperation(value = "Add data for a single gauge metric.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Adding data succeeded."),
            @ApiResponse(code = 400, message = "Missing or invalid payload", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error happened while storing the data",
                    response = ApiError.class),
    })
    public void addMetricData(
            @Suspended final AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "List of datapoints containing timestamp and value", required = true)
            List<DataPoint<Double>> data
    ) {
        Observable<Metric<Double>> metrics = Functions.dataPointToObservable(getTenant(), id, data, GAUGE);
        Observable<Void> observable = metricsService.addDataPoints(GAUGE, metrics);
        observable.subscribe(new ResultSetObserver(asyncResponse));
    }

    @Deprecated
    @POST
    @Path("/{id}/data")
    @ApiOperation(value = "Deprecated. Please use /raw endpoint.")
    public void deprecatedAddDataForMetric(
            @Suspended final AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "List of datapoints containing timestamp and value", required = true)
            List<DataPoint<Double>> data
    ) {
        addMetricData(asyncResponse, id, data);
    }

    @POST
    @Path("/raw")
    @ApiOperation(value = "Add data for multiple gauge metrics in a single call.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Adding data succeeded."),
            @ApiResponse(code = 400, message = "Missing or invalid payload", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error happened while storing the data",
                response = ApiError.class)
    })
    public void addData(
            @Suspended final AsyncResponse asyncResponse,
            @ApiParam(value = "List of metrics", required = true) List<Metric<Double>> gauges) {
        Observable<Metric<Double>> metrics = Functions.metricToObservable(getTenant(), gauges, GAUGE);
        Observable<Void> observable = metricsService.addDataPoints(GAUGE, metrics);
        observable.subscribe(new ResultSetObserver(asyncResponse));
    }

    @POST
    @Path("/raw/query")
    @ApiOperation(value = "Fetch raw data points for multiple metrics. This endpoint is experimental and may " +
            "undergo non-backwards compatible changes in future releases.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data points."),
            @ApiResponse(code = 204, message = "Query was successful, but no data was found."),
            @ApiResponse(code = 400, message = "No metric ids are specified", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    @Override
    public void getData(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(required = true, value = "Query parameters that minimally must include a list of metric ids or " +
                    "tags. The standard start, end, order, and limit query parameters are supported as well.")
                    QueryRequest query) {
        findMetricsByNameOrTag(query.getIds(), query.getTags(), GAUGE)
                .toList()
                .flatMap(metricIds -> TimeAndSortParams.<Double>deferredBuilder(query.getStart(), query.getEnd())
                        .fromEarliest(query.getFromEarliest(), metricIds, this::findTimeRange)
                        .sortOptions(query.getLimit(), query.getOrder())
                        .toObservable()
                        .flatMap(p -> metricsService.findDataPoints(metricIds, p.getTimeRange().getStart(),
                                p.getTimeRange().getEnd(), p.getLimit(), p.getOrder())
                                .observeOn(Schedulers.io())))
                .subscribe(createNamedDataPointObserver(asyncResponse, GAUGE));
    }

    @POST
    @Path("/rate/query")
    @ApiOperation(value = "Fetch rate data points for multiple metrics. This endpoint is experimental and may " +
            "undergo non-backwards compatible changes in future releases.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric rate data points."),
            @ApiResponse(code = 204, message = "Query was successful, but no data was found."),
            @ApiResponse(code = 400, message = "No metric ids are specified", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getRateData(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(required = true, value = "Query parameters that minimally must include a list of metric ids or " +
                    "tags. The standard start, end, order, and limit query parameters are supported as well.")
                    QueryRequest query) {
        findMetricsByNameOrTag(query.getIds(), query.getTags(), GAUGE)
                .toList()
                .flatMap(metricIds -> TimeAndSortParams.<Double>deferredBuilder(query.getStart(), query.getEnd())
                        .fromEarliest(query.getFromEarliest(), metricIds, this::findTimeRange)
                        .sortOptions(query.getLimit(), query.getOrder())
                        .toObservable()
                        .flatMap(p -> metricsService.findRateData(metricIds, p.getTimeRange().getStart(),
                                p.getTimeRange().getEnd(), p.getLimit(), p.getOrder())
                                .observeOn(Schedulers.io())))
                .subscribe(createNamedDataPointObserver(asyncResponse, GAUGE_RATE));
    }

    @Deprecated
    @POST
    @Path("/data")
    @ApiOperation(value = "Deprecated. Please use /raw endpoint.")
    public void deprecatedAddGaugeData(
            @Suspended final AsyncResponse asyncResponse,
            @ApiParam(value = "List of metrics", required = true) List<Metric<Double>> gauges
    ) {
        addData(asyncResponse, gauges);
    }

    @Deprecated
    @GET
    @Path("/{id}/data")
    @ApiOperation(value = "Deprecated. Please use /raw or /stats endpoints.",
                    response = DataPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "buckets or bucketDuration parameter is invalid, or both are used.",
                    response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void deprecatedFindGaugeData(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period")
                @QueryParam("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Total number of buckets") @QueryParam("buckets") Integer bucketsCount,
            @ApiParam(value = "Bucket duration") @QueryParam("bucketDuration") Duration bucketDuration,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles,
            @ApiParam(value = "Limit the number of data points returned") @QueryParam("limit") Integer limit,
            @ApiParam(value = "Data point sort order, based on timestamp") @QueryParam("order") Order order
    ) {

        MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);

        if ((bucketsCount != null || bucketDuration != null) &&
                (limit != null || order != null)) {
            asyncResponse.resume(badRequest(new ApiError("Limit and order cannot be used with bucketed results")));
            return;
        }
        if (bucketsCount == null && bucketDuration == null && !Boolean.TRUE.equals(fromEarliest)) {
            TimeRange timeRange = new TimeRange(start, end);
            if (!timeRange.isValid()) {
                asyncResponse.resume(badRequest(new ApiError(timeRange.getProblem())));
                return;
            }

            if (limit == null) {
                limit = 0;
            }
            if (order == null) {
                order = Order.defaultValue(limit, start, end);
            }

            metricsService.findDataPoints(metricId, timeRange.getStart(), timeRange.getEnd(), limit, order)
                    .toList()
                    .map(ApiUtils::collectionToResponse)
                    .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.serverError(t)));

            return;
        }

        Observable<BucketConfig> observableConfig;

        if (Boolean.TRUE.equals(fromEarliest)) {
            if (start != null || end != null) {
                asyncResponse.resume(badRequest(new ApiError("fromEarliest can only be used without start & end")));
                return;
            }

            if (bucketsCount == null && bucketDuration == null) {
                asyncResponse.resume(badRequest(new ApiError("fromEarliest can only be used with bucketed results")));
                return;
            }

            observableConfig = metricsService.findMetric(metricId).map((metric) -> {
                long dataRetention = metric.getDataRetention() * 24 * 60 * 60 * 1000L;
                long now = System.currentTimeMillis();
                long earliest = now - dataRetention;

                BucketConfig bucketConfig = new BucketConfig(bucketsCount, bucketDuration,
                        new TimeRange(earliest, now));

                if (!bucketConfig.isValid()) {
                    throw new RuntimeApiError(bucketConfig.getProblem());
                }

                return bucketConfig;
            });
        } else {
            TimeRange timeRange = new TimeRange(start, end);
            if (!timeRange.isValid()) {
                asyncResponse.resume(badRequest(new ApiError(timeRange.getProblem())));
                return;
            }

            BucketConfig bucketConfig = new BucketConfig(bucketsCount, bucketDuration, timeRange);
            if (!bucketConfig.isValid()) {
                asyncResponse.resume(badRequest(new ApiError(bucketConfig.getProblem())));
                return;
            }

            observableConfig = Observable.just(bucketConfig);
        }

        observableConfig
                .flatMap((config) -> {
                    List<Percentile> perc = percentiles == null ? Collections.emptyList() : percentiles.getPercentiles();
                    return metricsService.findGaugeStats(metricId, config, perc);
                })
                .flatMap(Observable::from)
                .skipWhile(bucket -> Boolean.TRUE.equals(fromEarliest) && bucket.isEmpty())
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/{id}/raw")
    @ApiOperation(value = "Retrieve raw gauge data.", response = DataPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getMetricData(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period")
                @QueryParam("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Limit the number of data points returned") @QueryParam("limit") Integer limit,
            @ApiParam(value = "Data point sort order, based on timestamp") @QueryParam("order") Order order
            ) {
        MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);
        TimeAndSortParams.<Double>deferredBuilder(start, end)
                .fromEarliest(fromEarliest, metricId, this::findTimeRange)
                .sortOptions(limit, order)
                .toObservable()
                .flatMap(p -> metricsService.findDataPoints(metricId, p.getTimeRange().getStart(), p.getTimeRange()
                        .getEnd(), p.getLimit(), p.getOrder()))
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/{id}/stats")
    @ApiOperation(value = "Retrieve gauge data.", notes = "The time range between start and end will be divided "
            + "in buckets of equal duration, and metric statistics will be computed for each bucket.",
                    response = NumericBucketPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "buckets or bucketDuration parameter is invalid, or both are used.",
                    response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getMetricStats(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period")
                                                @QueryParam("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Total number of buckets") @QueryParam("buckets") Integer bucketsCount,
            @ApiParam(value = "Bucket duration") @QueryParam("bucketDuration") Duration bucketDuration,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles) {

        MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);
        TimeAndBucketParams.<Double>deferredBuilder(start, end)
                .fromEarliest(fromEarliest, metricId, this::findTimeRange)
                .bucketConfig(bucketsCount, bucketDuration)
                .percentiles(percentiles)
                .toObservable()
                .flatMap(p -> metricsService.findGaugeStats(metricId, p.getBucketConfig(), p.getPercentiles()))
                .flatMap(Observable::from)
                .skipWhile(bucket -> Boolean.TRUE.equals(fromEarliest) && bucket.isEmpty())
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/stats")
    @ApiOperation(value = "Find stats for multiple metrics.", notes = "Fetches data points from one or more metrics"
            + " that are determined using either a tags filter or a list of metric names. The time range between " +
            "start and end is divided into buckets of equal size (i.e., duration) using either the buckets or " +
            "bucketDuration parameter. Functions are applied to the data points in each bucket to produce statistics " +
            "on aggregated metrics.",
            response = NumericBucketPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "The tags parameter is required. Either the buckets or the " +
                    "bucketDuration parameter is required but not both.",
                    response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class) })
    public void getStats(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") final String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") final String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period")
                                                @QueryParam("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Total number of buckets") @QueryParam("buckets") Integer bucketsCount,
            @ApiParam(value = "Bucket duration") @QueryParam("bucketDuration") Duration bucketDuration,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles,
            @ApiParam(value = "List of tags filters") @QueryParam("tags") String tags,
            @ApiParam(value = "List of metric names") @QueryParam("metrics") List<String> metricNames,
            @ApiParam(value = "Downsample method (if true then sum of stacked individual stats; defaults to false)")
            @DefaultValue("false") @QueryParam("stacked") Boolean stacked) {

        findMetricsByNameOrTag(metricNames, tags, MetricType.GAUGE)
                .toList()
                .flatMap(metricIds -> TimeAndBucketParams.<Double>deferredBuilder(start, end)
                        .fromEarliest(fromEarliest, metricIds, this::findTimeRange)
                        .bucketConfig(bucketsCount, bucketDuration)
                        .percentiles(percentiles)
                        .toObservable()
                        .flatMap(p -> metricsService.findNumericStats(metricIds, p.getTimeRange().getStart(),
                                p.getTimeRange().getEnd(), p.getBucketConfig().getBuckets(), p.getPercentiles(),
                                stacked, false)))
                .flatMap(Observable::from)
                .skipWhile(bucket -> Boolean.TRUE.equals(fromEarliest) && bucket.isEmpty())
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @POST
    @Path("/stats/query")
    @ApiOperation(value = "Find stats for multiple metrics. These metrics are aggregated into a single statistics " +
            "series.")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "Query was successful, but no data was found."),
            @ApiResponse(code = 400, message = "Either tags or metric ids is required but not both. Either the buckets or the " +
                    "bucketDuration parameter is required but not both.", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getStats(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(required = true, value = "Query parameters that minimally must include a list of metric ids. " +
                    "The standard start, end, order, and limit query parameters are supported as well.")
                    AggregatedStatsQueryRequest query) {
        findMetricsByNameOrTag(query.getMetrics(), query.getTags(), MetricType.GAUGE)
                .toList()
                .flatMap(metricIds -> TimeAndBucketParams.<Double>deferredBuilder(query.getStart(), query.getEnd())
                        .fromEarliest(query.getFromEarliest(), metricIds, this::findTimeRange)
                        .bucketConfig(query.getBuckets(), query.getBucketDuration())
                        .percentiles(query.getPercentiles())
                        .toObservable()
                        .flatMap(p -> metricsService.findNumericStats(metricIds, p.getTimeRange().getStart(),
                                p.getTimeRange().getEnd(), p.getBucketConfig().getBuckets(), p.getPercentiles(),
                                query.isStacked(), false)))
                .flatMap(Observable::from)
                .skipWhile(bucket -> Boolean.TRUE.equals(query.getFromEarliest()) && bucket.isEmpty())
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/{id}/stats/tags/{tags}")
    @ApiOperation(value = "Fetches data points and groups them into buckets based on one or more tag filters. The " +
            "data points in each bucket are then transformed into aggregated (i.e., bucket) data points.",
            response = TaggedBucketPoint.class, responseContainer = "Map")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "Tags are invalid",
                    response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getMetricStatsByTags(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles,
            @ApiParam(value = "Tags") @PathParam("tags") Tags tags
    ) {
        TimeRange timeRange = new TimeRange(start, end);
        if (!timeRange.isValid()) {
            asyncResponse.resume(badRequest(new ApiError(timeRange.getProblem())));
            return;
        }
        MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);
        if (percentiles == null) {
            percentiles = new Percentiles(Collections.emptyList());
        }
        metricsService.findGaugeStats(metricId, tags.getTags(), timeRange.getStart(), timeRange.getEnd(),
                percentiles.getPercentiles())
                .map(ApiUtils::mapToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.serverError(t)));
    }

    @Deprecated
    @GET
    @Path("/data")
    @ApiOperation(value = "Deprecated. Please use /stats endpoint.",
            response = NumericBucketPoint.class, responseContainer = "List")
    public void deprecatedFindData(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") final String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") final String end,
            @ApiParam(value = "Total number of buckets") @QueryParam("buckets") Integer bucketsCount,
            @ApiParam(value = "Bucket duration") @QueryParam("bucketDuration") Duration bucketDuration,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles,
            @ApiParam(value = "List of tags filters") @QueryParam("tags") String tags,
            @ApiParam(value = "List of metric names") @QueryParam("metrics") List<String> metricNames,
            @ApiParam(value = "Downsample method (if true then sum of stacked individual stats; defaults to false)")
            @DefaultValue("false") @QueryParam("stacked") Boolean stacked) {

        getStats(asyncResponse, start, end, null, bucketsCount, bucketDuration, percentiles, tags, metricNames,
                stacked);
    }

    @GET
    @Path("/{id}/periods")
    @ApiOperation(value = "Find condition periods.", notes = "Retrieve periods for which the condition holds true for" +
            " each consecutive data point.", response = List.class)
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched periods."),
            @ApiResponse(code = 204, message = "No data was found."),
            @ApiResponse(code = 400, message = "Missing or invalid query parameters", response = ApiError.class)
    })
    public void getMetricPeriods(
            @Suspended final AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "A threshold against which values are compared", required = true)
            @QueryParam("threshold") double threshold,
            @ApiParam(value = "A comparison operation to perform between values and the threshold.", required = true,
                    allowableValues = "ge, gte, lt, lte, eq, neq")
            @QueryParam("op") String operator
    ) {
        TimeRange timeRange = new TimeRange(start, end);
        if (!timeRange.isValid()) {
            asyncResponse.resume(badRequest(new ApiError(timeRange.getProblem())));
            return;
        }

        Predicate<Double> predicate;
        switch (operator) { // Why not enum?
            case "lt":
                predicate = d -> d < threshold;
                break;
            case "lte":
                predicate = d -> d <= threshold;
                break;
            case "eq":
                predicate = d -> d == threshold;
                break;
            case "neq":
                predicate = d -> d != threshold;
                break;
            case "gt":
                predicate = d -> d > threshold;
                break;
            case "gte":
                predicate = d -> d >= threshold;
                break;
            default:
                predicate = null;
        }

        if (predicate == null) {
            asyncResponse.resume(badRequest(
                    new ApiError(
                            "Invalid value for op parameter. Supported values are lt, "
                                    + "lte, eq, gt, gte."
                    )
            ));
        } else {
            MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);
            metricsService.getPeriods(metricId, predicate, timeRange.getStart(), timeRange.getEnd())
                    .map(ApiUtils::collectionToResponse)
                    .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.serverError(t)));
        }
    }

    @GET
    @Path("/{id}/rate")
    @ApiOperation(value = "Retrieve gauge rate data points.", response = DataPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "Time range is invalid.", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getMetricRate(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period") @QueryParam
                    ("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Limit the number of data points returned") @QueryParam("limit") Integer limit,
            @ApiParam(value = "Data point sort order, based on timestamp") @QueryParam("order") Order order
    ) {
        MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);
        TimeAndSortParams.<Double>deferredBuilder(start, end)
                .fromEarliest(fromEarliest, metricId, this::findTimeRange)
                .sortOptions(limit, order)
                .toObservable()
                .flatMap(p -> metricsService.findRateData(metricId, p.getTimeRange().getStart(), p.getTimeRange()
                        .getEnd(), p.getLimit(), p.getOrder()))
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/{id}/rate/stats")
    @ApiOperation(
            value = "Retrieve stats for gauge rate data points.", notes = "The time range between start and end " +
            "will be divided in buckets of equal duration, and metric statistics will be computed for each bucket.",
            response = NumericBucketPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "buckets or bucketDuration parameter is invalid, or both are used.",
                    response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getMetricRateStats(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("id") String id,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period") @QueryParam
                    ("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Total number of buckets") @QueryParam("buckets") Integer bucketsCount,
            @ApiParam(value = "Bucket duration") @QueryParam("bucketDuration") Duration bucketDuration,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles
    ) {
        MetricId<Double> metricId = new MetricId<>(getTenant(), GAUGE, id);
        TimeAndBucketParams.<Double>deferredBuilder(start, end)
                .fromEarliest(fromEarliest, metricId, this::findTimeRange)
                .bucketConfig(bucketsCount, bucketDuration)
                .percentiles(percentiles)
                .toObservable()
                .flatMap(p -> metricsService.findRateStats(metricId, p.getBucketConfig(), p.getPercentiles()))
                .flatMap(Observable::from)
                .skipWhile(bucket -> Boolean.TRUE.equals(fromEarliest) && bucket.isEmpty())
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/rate/stats")
    @ApiOperation(value = "Fetches data points from one or more metrics that are determined using either a tags " +
            "filter or a list of metric names. The time range between start and end is divided into buckets of " +
            "equal size (i.e., duration) using either the buckets or bucketDuration parameter. Functions are " +
            "applied to the data points in each bucket to produce statistics or aggregated metrics.",
            response = NumericBucketPoint.class, responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data."),
            @ApiResponse(code = 204, message = "No metric data was found."),
            @ApiResponse(code = 400, message = "The tags parameter is required. Either the buckets or the " +
                    "bucketDuration parameter is required but not both.", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)})
    public void getRateStats(
            @Suspended AsyncResponse asyncResponse,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") final String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") final String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period") @QueryParam
                    ("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Total number of buckets") @QueryParam("buckets") Integer bucketsCount,
            @ApiParam(value = "Bucket duration") @QueryParam("bucketDuration") Duration bucketDuration,
            @ApiParam(value = "Percentiles to calculate") @QueryParam("percentiles") Percentiles percentiles,
            @ApiParam(value = "List of tags filters") @QueryParam("tags") String tags,
            @ApiParam(value = "List of metric names") @QueryParam("metrics") List<String> metricNames,
            @ApiParam(value = "Downsample method (if true then sum of stacked individual stats; defaults to false)")
            @DefaultValue("false") @QueryParam("stacked") Boolean stacked) {

        findMetricsByNameOrTag(metricNames, tags, MetricType.GAUGE)
                .toList()
                .flatMap(metricIds -> TimeAndBucketParams.<Double>deferredBuilder(start, end)
                        .fromEarliest(fromEarliest, metricIds, this::findTimeRange)
                        .bucketConfig(bucketsCount, bucketDuration)
                        .percentiles(percentiles)
                        .toObservable()
                        .flatMap(p -> metricsService.findNumericStats(metricIds, p.getTimeRange().getStart(),
                                p.getTimeRange().getEnd(), p.getBucketConfig().getBuckets(), p.getPercentiles(),
                                stacked, true)))
                .flatMap(Observable::from)
                .skipWhile(bucket -> Boolean.TRUE.equals(fromEarliest) && bucket.isEmpty())
                .toList()
                .map(ApiUtils::collectionToResponse)
                .subscribe(asyncResponse::resume, t -> asyncResponse.resume(ApiUtils.error(t)));
    }

    @GET
    @Path("/tags/{tags}/raw")
    @ApiOperation(value = "Retrieve raw gauge data on multiple metrics by tags.", response = DataPoint.class,
            responseContainer = "List")
    @ApiResponses(value = {
            @ApiResponse(code = 200, message = "Successfully fetched metric data points."),
            @ApiResponse(code = 204, message = "Query was successful, but no data was found."),
            @ApiResponse(code = 400, message = "No metric ids are specified", response = ApiError.class),
            @ApiResponse(code = 500, message = "Unexpected error occurred while fetching metric data.",
                    response = ApiError.class)
    })
    public void getRawDataByTag(
            @Suspended AsyncResponse asyncResponse,
            @PathParam("tags") String tags,
            @ApiParam(value = "Defaults to now - 8 hours") @QueryParam("start") String start,
            @ApiParam(value = "Defaults to now") @QueryParam("end") String end,
            @ApiParam(value = "Use data from earliest received, subject to retention period")
            @QueryParam("fromEarliest") Boolean fromEarliest,
            @ApiParam(value = "Limit the number of data points returned") @QueryParam("limit") Integer limit,
            @ApiParam(value = "Data point sort order, based on timestamp") @QueryParam("order") Order order
    ) {
        metricsService.findMetricIdentifiersWithFilters(getTenant(), GAUGE, tags)
                .toList()
                .flatMap(metricIds -> TimeAndSortParams.<Double>deferredBuilder(start, end)
                        .fromEarliest(fromEarliest, metricIds, this::findTimeRange)
                        .sortOptions(limit, order)
                        .toObservable()
                        .flatMap(p -> metricsService.findDataPoints(metricIds, p.getTimeRange().getStart(),
                                p.getTimeRange().getEnd(), p.getLimit(), p.getOrder())
                                .observeOn(Schedulers.io())))
                .subscribe(createNamedDataPointObserver(asyncResponse, GAUGE));
    }
}