package io.github.azagniotov.metrics.reporter.cloudwatch;

import software.amazon.awssdk.services.cloudwatch.CloudWatchAsyncClient;
import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataResponse;
import software.amazon.awssdk.services.cloudwatch.model.PutMetricDataRequest;
import software.amazon.awssdk.services.cloudwatch.model.StandardUnit;
import software.amazon.awssdk.services.cloudwatch.model.MetricDatum;
import software.amazon.awssdk.services.cloudwatch.model.StatisticSet;
import software.amazon.awssdk.services.cloudwatch.model.Dimension;
import software.amazon.awssdk.services.cloudwatch.model.InvalidParameterValueException;

import com.codahale.metrics.Clock;
import com.codahale.metrics.Counter;
import com.codahale.metrics.Counting;
import com.codahale.metrics.Gauge;
import com.codahale.metrics.Histogram;
import com.codahale.metrics.Meter;
import com.codahale.metrics.Metered;
import com.codahale.metrics.MetricFilter;
import com.codahale.metrics.MetricRegistry;
import com.codahale.metrics.ScheduledReporter;
import com.codahale.metrics.Snapshot;
import com.codahale.metrics.Timer;
import com.codahale.metrics.jvm.BufferPoolMetricSet;
import com.codahale.metrics.jvm.ClassLoadingGaugeSet;
import com.codahale.metrics.jvm.FileDescriptorRatioGauge;
import com.codahale.metrics.jvm.GarbageCollectorMetricSet;
import com.codahale.metrics.jvm.MemoryUsageGaugeSet;
import com.codahale.metrics.jvm.ThreadStatesGaugeSet;
import io.github.azagniotov.metrics.reporter.utils.CollectionsUtils;

import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.lang.management.ManagementFactory;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.LongStream;
import java.util.stream.Stream;

/**
 * Reports metrics to <a href="http://aws.amazon.com/cloudwatch/">Amazon's CloudWatch</a> periodically.
 * <p>
 * Use {@link CloudWatchReporter.Builder} to construct instances of this class. The {@link CloudWatchReporter.Builder}
 * allows to configure what aggregated metrics will be reported as a single {@link MetricDatum} to CloudWatch.
 * <p>
 * There are a bunch of {@code with*} methods that provide a sufficient fine-grained control over what metrics
 * should be reported
 */
public class CloudWatchReporter extends ScheduledReporter {

    private static final Logger LOGGER = LoggerFactory.getLogger(CloudWatchReporter.class);

    // Visible for testing
    static final String DIMENSION_NAME_TYPE = "Type";

    // Visible for testing
    static final String DIMENSION_GAUGE = "gauge";

    // Visible for testing
    static final String DIMENSION_COUNT = "count";

    // Visible for testing
    static final String DIMENSION_SNAPSHOT_SUMMARY = "snapshot-summary";

    // Visible for testing
    static final String DIMENSION_SNAPSHOT_MEAN = "snapshot-mean";

    // Visible for testing
    static final String DIMENSION_SNAPSHOT_STD_DEV = "snapshot-std-dev";

    /**
     * PutMetricData function accepts an optional StorageResolution parameter.
     * 1 = publish high-resolution metrics, 60 = publish at standard 1-minute resolution.
    */
    private static final int HIGH_RESOLUTION = 1;
    private static final int STANDARD_RESOLUTION = 60;

    /**
     * Amazon CloudWatch rejects values that are either too small or too large.
     * Values must be in the range of 8.515920e-109 to 1.174271e+108 (Base 10) or 2e-360 to 2e360 (Base 2).
     * <p>
     * In addition, special values (e.g., NaN, +Infinity, -Infinity) are not supported.
     */
    private static final double SMALLEST_SENDABLE_VALUE = 8.515920e-109;
    private static final double LARGEST_SENDABLE_VALUE = 1.174271e+108;

    /**
     * Each CloudWatch API request may contain at maximum 20 datums
     */
    private static final int MAXIMUM_DATUMS_PER_REQUEST = 20;

    /**
     * We only submit the difference in counters since the last submission. This way we don't have to reset
     * the counters within this application.
     */
    private final Map<Counting, Long> lastPolledCounts;

    private final Builder builder;
    private final String namespace;
    private final CloudWatchAsyncClient cloudWatchAsyncClient;
    private final StandardUnit rateUnit;
    private final StandardUnit durationUnit;
    private final boolean highResolution;

    private CloudWatchReporter(final Builder builder) {
        super(builder.metricRegistry, "coda-hale-metrics-cloud-watch-reporter", builder.metricFilter, builder.rateUnit, builder.durationUnit);
        this.builder = builder;
        this.namespace = builder.namespace;
        this.cloudWatchAsyncClient = builder.cloudWatchAsyncClient;
        this.lastPolledCounts = new ConcurrentHashMap<>();
        this.rateUnit = builder.cwRateUnit;
        this.durationUnit = builder.cwDurationUnit;
        this.highResolution = builder.highResolution;
    }

    @Override
    public void report(final SortedMap<String, Gauge> gauges,
                       final SortedMap<String, Counter> counters,
                       final SortedMap<String, Histogram> histograms,
                       final SortedMap<String, Meter> meters,
                       final SortedMap<String, Timer> timers) {

        if (builder.withDryRun) {
            LOGGER.warn("** Reporter is running in 'DRY RUN' mode **");
        }

        try {
            final List<MetricDatum> metricData = new ArrayList<>(
                    gauges.size() + counters.size() + 10 * histograms.size() + 10 * timers.size());

            for (final Map.Entry<String, Gauge> gaugeEntry : gauges.entrySet()) {
                processGauge(gaugeEntry.getKey(), gaugeEntry.getValue(), metricData);
            }

            for (final Map.Entry<String, Counter> counterEntry : counters.entrySet()) {
                processCounter(counterEntry.getKey(), counterEntry.getValue(), metricData);
            }

            for (final Map.Entry<String, Histogram> histogramEntry : histograms.entrySet()) {
                processCounter(histogramEntry.getKey(), histogramEntry.getValue(), metricData);
                processHistogram(histogramEntry.getKey(), histogramEntry.getValue(), metricData);
            }

            for (final Map.Entry<String, Meter> meterEntry : meters.entrySet()) {
                processCounter(meterEntry.getKey(), meterEntry.getValue(), metricData);
                processMeter(meterEntry.getKey(), meterEntry.getValue(), metricData);
            }

            for (final Map.Entry<String, Timer> timerEntry : timers.entrySet()) {
                processCounter(timerEntry.getKey(), timerEntry.getValue(), metricData);
                processMeter(timerEntry.getKey(), timerEntry.getValue(), metricData);
                processTimer(timerEntry.getKey(), timerEntry.getValue(), metricData);
            }

            final Collection<List<MetricDatum>> metricDataPartitions = CollectionsUtils.partition(metricData, MAXIMUM_DATUMS_PER_REQUEST);
            final List<Future<PutMetricDataResponse>> cloudWatchFutures = new ArrayList<>(metricData.size());

            for (final List<MetricDatum> partition : metricDataPartitions) {
                final PutMetricDataRequest putMetricDataRequest = PutMetricDataRequest
                        .builder()
                        .namespace(namespace)
                        .metricData(partition)
                        .build();

                if (builder.withDryRun) {
                    if (LOGGER.isDebugEnabled()) {
                        LOGGER.debug("Dry run - constructed PutMetricDataRequest: {}", putMetricDataRequest);
                    }
                } else {
                    cloudWatchFutures.add(cloudWatchAsyncClient.putMetricData(putMetricDataRequest));
                }
            }

            for (final Future<PutMetricDataResponse> cloudWatchFuture : cloudWatchFutures) {
                try {
                    cloudWatchFuture.get();
                } catch (final Exception e) {
                    LOGGER.error("Error reporting metrics to CloudWatch. The data in this CloudWatch API request " +
                            "may have been discarded, did not make it to CloudWatch.", e);
                }
            }

            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("Sent {} metric datums to CloudWatch. Namespace: {}, metric data {}", metricData.size(), namespace, metricData);
            }

        } catch (final RuntimeException e) {
            LOGGER.error("Error marshalling CloudWatch metrics.", e);
        }
    }

    @Override
    public void stop() {
        try {
            super.stop();
        } catch (final Exception e) {
            LOGGER.error("Error when stopping the reporter.", e);
        } finally {
            if (!builder.withDryRun) {
                try {
                    cloudWatchAsyncClient.close();
                } catch (final Exception e) {
                    LOGGER.error("Error shutting down AmazonCloudWatchAsync", cloudWatchAsyncClient, e);
                }
            }
        }
    }

    private void processGauge(final String metricName, final Gauge gauge, final List<MetricDatum> metricData) {
        Optional.ofNullable(gauge.getValue())
            .filter(value -> value instanceof Number)
            .map(value -> (Number) value)
            .ifPresent(value -> stageMetricDatum(true, metricName, value.doubleValue(), StandardUnit.NONE, DIMENSION_GAUGE, metricData));
    }

    private void processCounter(final String metricName, final Counting counter, final List<MetricDatum> metricData) {
        long currentCount = counter.getCount();
        Long lastCount = lastPolledCounts.get(counter);
        lastPolledCounts.put(counter, currentCount);

        if (lastCount == null) {
            lastCount = 0L;
        }

        final long reportValue;
        if (builder.withReportRawCountValue) {
            reportValue = currentCount;
        } else {
            // Only submit metrics that have changed - let's save some money!
            reportValue = currentCount - lastCount;
        }

        stageMetricDatum(true, metricName, reportValue, StandardUnit.COUNT, DIMENSION_COUNT, metricData);
    }

    /**
     * The rates of {@link Metered} are reported after being converted using the rate factor, which is deduced from
     * the set rate unit
     *
     * @see Timer#getSnapshot
     * @see #getRateUnit
     * @see #convertRate(double)
     */
    private void processMeter(final String metricName, final Metered meter, final List<MetricDatum> metricData) {
        final String formattedRate = String.format("-rate [per-%s]", getRateUnit());
        stageMetricDatum(builder.withOneMinuteMeanRate, metricName, convertRate(meter.getOneMinuteRate()), rateUnit, "1-min-mean" + formattedRate, metricData);
        stageMetricDatum(builder.withFiveMinuteMeanRate, metricName, convertRate(meter.getFiveMinuteRate()), rateUnit, "5-min-mean" + formattedRate, metricData);
        stageMetricDatum(builder.withFifteenMinuteMeanRate, metricName, convertRate(meter.getFifteenMinuteRate()), rateUnit, "15-min-mean" + formattedRate, metricData);
        stageMetricDatum(builder.withMeanRate, metricName, convertRate(meter.getMeanRate()), rateUnit, "mean" + formattedRate, metricData);
    }

    /**
     * The {@link Snapshot} values of {@link Timer} are reported as {@link StatisticSet} after conversion. The
     * conversion is done using the duration factor, which is deduced from the set duration unit.
     * <p>
     * Please note, the reported values submitted only if they show some data (greater than zero) in order to:
     * <p>
     * 1. save some money
     * 2. prevent com.amazonaws.services.cloudwatch.model.InvalidParameterValueException if empty {@link Snapshot}
     * is submitted
     * <p>
     * If {@link Builder#withZeroValuesSubmission()} is {@code true}, then all values will be submitted
     *
     * @see Timer#getSnapshot
     * @see #getDurationUnit
     * @see #convertDuration(double)
     */
    private void processTimer(final String metricName, final Timer timer, final List<MetricDatum> metricData) {
        final Snapshot snapshot = timer.getSnapshot();

        if (builder.withZeroValuesSubmission || snapshot.size() > 0) {
            for (final Percentile percentile : builder.percentiles) {
                final double convertedDuration = convertDuration(snapshot.getValue(percentile.getQuantile()));
                stageMetricDatum(true, metricName, convertedDuration, durationUnit, percentile.getDesc(), metricData);
            }
        }

        // prevent empty snapshot from causing InvalidParameterValueException
        if (snapshot.size() > 0) {
            final String formattedDuration = String.format(" [in-%s]", getDurationUnit());
            stageMetricDatum(builder.withArithmeticMean, metricName, convertDuration(snapshot.getMean()), durationUnit, DIMENSION_SNAPSHOT_MEAN + formattedDuration, metricData);
            stageMetricDatum(builder.withStdDev, metricName, convertDuration(snapshot.getStdDev()), durationUnit, DIMENSION_SNAPSHOT_STD_DEV + formattedDuration, metricData);
            stageMetricDatumWithConvertedSnapshot(builder.withStatisticSet, metricName, snapshot, durationUnit, metricData);
        }
    }

    /**
     * The {@link Snapshot} values of {@link Histogram} are reported as {@link StatisticSet} raw. In other words, the
     * conversion using the duration factor does NOT apply.
     * <p>
     * Please note, the reported values submitted only if they show some data (greater than zero) in order to:
     * <p>
     * 1. save some money
     * 2. prevent com.amazonaws.services.cloudwatch.model.InvalidParameterValueException if empty {@link Snapshot}
     * is submitted
     * <p>
     * If {@link Builder#withZeroValuesSubmission()} is {@code true}, then all values will be submitted
     *
     * @see Histogram#getSnapshot
     */
    private void processHistogram(final String metricName, final Histogram histogram, final List<MetricDatum> metricData) {
        final Snapshot snapshot = histogram.getSnapshot();

        if (builder.withZeroValuesSubmission || snapshot.size() > 0) {
            for (final Percentile percentile : builder.percentiles) {
                final double value = snapshot.getValue(percentile.getQuantile());
                stageMetricDatum(true, metricName, value, StandardUnit.NONE, percentile.getDesc(), metricData);
            }
        }

        // prevent empty snapshot from causing InvalidParameterValueException
        if (snapshot.size() > 0) {
            stageMetricDatum(builder.withArithmeticMean, metricName, snapshot.getMean(), StandardUnit.NONE, DIMENSION_SNAPSHOT_MEAN, metricData);
            stageMetricDatum(builder.withStdDev, metricName, snapshot.getStdDev(), StandardUnit.NONE, DIMENSION_SNAPSHOT_STD_DEV, metricData);
            stageMetricDatumWithRawSnapshot(builder.withStatisticSet, metricName, snapshot, StandardUnit.NONE, metricData);
        }
    }

    /**
     * Please note, the reported values submitted only if they show some data (greater than zero) in order to:
     * <p>
     * 1. save some money
     * 2. prevent com.amazonaws.services.cloudwatch.model.InvalidParameterValueException if empty {@link Snapshot}
     * is submitted
     * <p>
     * If {@link Builder#withZeroValuesSubmission()} is {@code true}, then all values will be submitted
     */
    private void stageMetricDatum(final boolean metricConfigured,
                                  final String metricName,
                                  final double metricValue,
                                  final StandardUnit standardUnit,
                                  final String dimensionValue,
                                  final List<MetricDatum> metricData) {
        // Only submit metrics that show some data, so let's save some money
        if (metricConfigured && (builder.withZeroValuesSubmission || metricValue > 0)) {
            final DimensionedName dimensionedName = DimensionedName.decode(metricName);

            final Set<Dimension> dimensions = new LinkedHashSet<>(builder.globalDimensions);
            dimensions.add(Dimension.builder().name(DIMENSION_NAME_TYPE).value(dimensionValue).build());
            dimensions.addAll(dimensionedName.getDimensions());

            metricData.add(MetricDatum.builder()
                    .timestamp(Instant.ofEpochMilli(builder.clock.getTime()))
                    .value(cleanMetricValue(metricValue))
                    .metricName(dimensionedName.getName())
                    .dimensions(dimensions)
                    .storageResolution(highResolution ? HIGH_RESOLUTION : STANDARD_RESOLUTION)
                    .unit(standardUnit)
                    .build());
        }
    }

    private void stageMetricDatumWithConvertedSnapshot(final boolean metricConfigured,
                                                       final String metricName,
                                                       final Snapshot snapshot,
                                                       final StandardUnit standardUnit,
                                                       final List<MetricDatum> metricData) {
        if (metricConfigured) {
            final DimensionedName dimensionedName = DimensionedName.decode(metricName);
            double scaledSum = convertDuration(LongStream.of(snapshot.getValues()).sum());
            final StatisticSet statisticSet = StatisticSet.builder()
                    .sum(scaledSum)
                    .sampleCount((double) snapshot.size())
                    .minimum(convertDuration(snapshot.getMin()))
                    .maximum(convertDuration(snapshot.getMax()))
                    .build();

            final Set<Dimension> dimensions = new LinkedHashSet<>(builder.globalDimensions);
            dimensions.add(Dimension.builder().name(DIMENSION_NAME_TYPE).value(DIMENSION_SNAPSHOT_SUMMARY).build());
            dimensions.addAll(dimensionedName.getDimensions());

            metricData.add(MetricDatum
                    .builder()
                    .timestamp(Instant.ofEpochMilli(builder.clock.getTime()))
                    .metricName(dimensionedName.getName())
                    .dimensions(dimensions)
                    .statisticValues(statisticSet)
                    .storageResolution(highResolution ? HIGH_RESOLUTION : STANDARD_RESOLUTION)
                    .unit(standardUnit)
                    .build());
        }
    }

    private void stageMetricDatumWithRawSnapshot(final boolean metricConfigured,
                                                 final String metricName,
                                                 final Snapshot snapshot,
                                                 final StandardUnit standardUnit,
                                                 final List<MetricDatum> metricData) {
        if (metricConfigured) {
            final DimensionedName dimensionedName = DimensionedName.decode(metricName);
            double total = LongStream.of(snapshot.getValues()).sum();
            final StatisticSet statisticSet =StatisticSet
                    .builder()
                    .sum(total)
                    .sampleCount((double) snapshot.size())
                    .minimum((double) snapshot.getMin())
                    .maximum((double) snapshot.getMax())
                    .build();

            final Set<Dimension> dimensions = new LinkedHashSet<>(builder.globalDimensions);
            dimensions.add(Dimension.builder().name(DIMENSION_NAME_TYPE).value(DIMENSION_SNAPSHOT_SUMMARY).build());
            dimensions.addAll(dimensionedName.getDimensions());

            metricData.add(MetricDatum
                    .builder()
                    .timestamp(Instant.ofEpochMilli(builder.clock.getTime()))
                    .metricName(dimensionedName.getName())
                    .dimensions(dimensions)
                    .statisticValues(statisticSet)
                    .storageResolution(highResolution ? HIGH_RESOLUTION : STANDARD_RESOLUTION)
                    .unit(standardUnit)
                    .build());
        }
    }

    private double cleanMetricValue(final double metricValue) {
        double absoluteValue = Math.abs(metricValue);
        if (absoluteValue < SMALLEST_SENDABLE_VALUE) {
            // Allow 0 through untouched, everything else gets rounded to SMALLEST_SENDABLE_VALUE
            if (absoluteValue > 0) {
                if (metricValue < 0) {
                    return -SMALLEST_SENDABLE_VALUE;
                } else {
                    return SMALLEST_SENDABLE_VALUE;
                }
            }
        } else if (absoluteValue > LARGEST_SENDABLE_VALUE) {
            if (metricValue < 0) {
                return -LARGEST_SENDABLE_VALUE;
            } else {
                return LARGEST_SENDABLE_VALUE;
            }
        }
        return metricValue;
    }

    /**
     * Creates a new {@link Builder} that sends values from the given {@link MetricRegistry} to the given namespace
     * using the given CloudWatch client.
     *
     * @param metricRegistry {@link MetricRegistry} instance
     * @param client         {@link CloudWatchAsyncClient} instance
     * @param namespace      the namespace. Must be non-null and not empty.
     * @return {@link Builder} instance
     */
    public static Builder forRegistry(final MetricRegistry metricRegistry, final CloudWatchAsyncClient client, final String namespace) {
        return new Builder(metricRegistry, client, namespace);
    }

    public enum Percentile {
        P50(0.50, "50%"),
        P75(0.75, "75%"),
        P95(0.95, "95%"),
        P98(0.98, "98%"),
        P99(0.99, "99%"),
        P995(0.995, "99.5%"),
        P999(0.999, "99.9%");

        private final double quantile;
        private final String desc;

        Percentile(final double quantile, final String desc) {
            this.quantile = quantile;
            this.desc = desc;
        }

        public double getQuantile() {
            return quantile;
        }

        public String getDesc() {
            return desc;
        }
    }

    public static class Builder {

        private final String namespace;
        private final CloudWatchAsyncClient cloudWatchAsyncClient;
        private final MetricRegistry metricRegistry;

        private Percentile[] percentiles;
        private boolean withOneMinuteMeanRate;
        private boolean withFiveMinuteMeanRate;
        private boolean withFifteenMinuteMeanRate;
        private boolean withMeanRate;
        private boolean withArithmeticMean;
        private boolean withStdDev;
        private boolean withDryRun;
        private boolean withZeroValuesSubmission;
        private boolean withStatisticSet;
        private boolean withJvmMetrics;
        private boolean withReportRawCountValue;
        private MetricFilter metricFilter;
        private TimeUnit rateUnit;
        private TimeUnit durationUnit;
        private Optional<StandardUnit> cwMeterUnit;
        private StandardUnit cwRateUnit;
        private StandardUnit cwDurationUnit;
        private Set<Dimension> globalDimensions;
        private final Clock clock;
        private boolean highResolution;

        private Builder(final MetricRegistry metricRegistry, final CloudWatchAsyncClient cloudWatchAsyncClient, final String namespace) {
            this.metricRegistry = metricRegistry;
            this.cloudWatchAsyncClient = cloudWatchAsyncClient;
            this.namespace = namespace;
            this.percentiles = new Percentile[]{Percentile.P75, Percentile.P95, Percentile.P999};
            this.metricFilter = MetricFilter.ALL;
            this.rateUnit = TimeUnit.SECONDS;
            this.durationUnit = TimeUnit.MILLISECONDS;
            this.globalDimensions = new LinkedHashSet<>();
            this.cwMeterUnit = Optional.empty();
            this.cwRateUnit = toStandardUnit(rateUnit);
            this.cwDurationUnit = toStandardUnit(durationUnit);
            this.clock = Clock.defaultClock();
        }

        /**
         * Convert rates to the given time unit.
         *
         * @param rateUnit a unit of time
         * @return {@code this}
         */
        public Builder convertRatesTo(final TimeUnit rateUnit) {
            this.rateUnit = rateUnit;
            return this;
        }

        /**
         * Convert durations to the given time unit.
         *
         * @param durationUnit a unit of time
         * @return {@code this}
         */
        public Builder convertDurationsTo(final TimeUnit durationUnit) {
            this.durationUnit = durationUnit;
            return this;
        }

        /**
         * Only report metrics which match the given filter.
         *
         * @param metricFilter a {@link MetricFilter}
         * @return {@code this}
         */
        public Builder filter(final MetricFilter metricFilter) {
            this.metricFilter = metricFilter;
            return this;
        }

        /**
         * If the one minute rate should be sent for {@link Meter} and {@link Timer}. {@code false} by default.
         * <p>
         * The rate values are converted before reporting based on the rate unit set
         *
         * @return {@code this}
         * @see ScheduledReporter#convertRate(double)
         * @see Meter#getOneMinuteRate()
         * @see Timer#getOneMinuteRate()
         */
        public Builder withOneMinuteMeanRate() {
            withOneMinuteMeanRate = true;
            return this;
        }

        /**
         * If the five minute rate should be sent for {@link Meter} and {@link Timer}. {@code false} by default.
         * <p>
         * The rate values are converted before reporting based on the rate unit set
         *
         * @return {@code this}
         * @see ScheduledReporter#convertRate(double)
         * @see Meter#getFiveMinuteRate()
         * @see Timer#getFiveMinuteRate()
         */
        public Builder withFiveMinuteMeanRate() {
            withFiveMinuteMeanRate = true;
            return this;
        }

        /**
         * If the fifteen minute rate should be sent for {@link Meter} and {@link Timer}. {@code false} by default.
         * <p>
         * The rate values are converted before reporting based on the rate unit set
         *
         * @return {@code this}
         * @see ScheduledReporter#convertRate(double)
         * @see Meter#getFifteenMinuteRate()
         * @see Timer#getFifteenMinuteRate()
         */
        public Builder withFifteenMinuteMeanRate() {
            withFifteenMinuteMeanRate = true;
            return this;
        }

        /**
         * If the mean rate should be sent for {@link Meter} and {@link Timer}. {@code false} by default.
         * <p>
         * The rate values are converted before reporting based on the rate unit set
         *
         * @return {@code this}
         * @see ScheduledReporter#convertRate(double)
         * @see Meter#getMeanRate()
         * @see Timer#getMeanRate()
         */
        public Builder withMeanRate() {
            withMeanRate = true;
            return this;
        }

        /**
         * If the arithmetic mean of {@link Snapshot} values in {@link Histogram} and {@link Timer} should be sent.
         * {@code false} by default.
         * <p>
         * The {@link Timer#getSnapshot()} values are converted before reporting based on the duration unit set
         * The {@link Histogram#getSnapshot()} values are reported as is
         *
         * @return {@code this}
         * @see ScheduledReporter#convertDuration(double)
         * @see Snapshot#getMean()
         */
        public Builder withArithmeticMean() {
            withArithmeticMean = true;
            return this;
        }

        /**
         * If the standard deviation of {@link Snapshot} values in {@link Histogram} and {@link Timer} should be sent.
         * {@code false} by default.
         * <p>
         * The {@link Timer#getSnapshot()} values are converted before reporting based on the duration unit set
         * The {@link Histogram#getSnapshot()} values are reported as is
         *
         * @return {@code this}
         * @see ScheduledReporter#convertDuration(double)
         * @see Snapshot#getStdDev()
         */
        public Builder withStdDev() {
            withStdDev = true;
            return this;
        }

        /**
         * If lifetime {@link Snapshot} summary of {@link Histogram} and {@link Timer} should be translated
         * to {@link StatisticSet} in the most direct way possible and reported. {@code false} by default.
         * <p>
         * The {@link Snapshot} duration values are converted before reporting based on the duration unit set
         *
         * @return {@code this}
         * @see ScheduledReporter#convertDuration(double)
         */
        public Builder withStatisticSet() {
            withStatisticSet = true;
            return this;
        }

        /**
         * If JVM statistic should be reported. Supported metrics include:
         * <p>
         * - Run count and elapsed times for all supported garbage collectors
         * - Memory usage for all memory pools, including off-heap memory
         * - Breakdown of thread states, including deadlocks
         * - File descriptor usage
         * - Buffer pool sizes and utilization (Java 7 only)
         * <p>
         * {@code false} by default.
         *
         * @return {@code this}
         */
        public Builder withJvmMetrics() {
            withJvmMetrics = true;
            return this;
        }

        /**
         * Does not actually POST to CloudWatch, logs the {@link PutMetricDataRequest putMetricDataRequest} instead.
         * {@code false} by default.
         *
         * @return {@code this}
         */
        public Builder withDryRun() {
            withDryRun = true;
            return this;
        }

        /**
         * POSTs to CloudWatch all values. Otherwise, the reporter does not POST values which are zero in order to save
         * costs. Also, some users have been experiencing {@link InvalidParameterValueException} when submitting zero
         * values. Please refer to:
         * https://github.com/azagniotov/codahale-aggregated-metrics-cloudwatch-reporter/issues/4
         * <p>
         * {@code false} by default.
         *
         * @return {@code this}
         */
        public Builder withZeroValuesSubmission() {
            withZeroValuesSubmission = true;
            return this;
        }

        /**
         * Will report the raw value of count metrics instead of reporting only the count difference since the last
         * report
         * {@code false} by default.
         *
         * @return {@code this}
         */
        public Builder withReportRawCountValue() {
            withReportRawCountValue = true;
            return this;
        }

        /**
         * The {@link Histogram} and {@link Timer} percentiles to send. If <code>0.5</code> is included, it'll be
         * reported as <code>median</code>.This defaults to <code>0.75, 0.95 and 0.999</code>.
         * <p>
         * The {@link Timer#getSnapshot()} percentile values are converted before reporting based on the duration unit
         * The {@link Histogram#getSnapshot()} percentile values are reported as is
         *
         * @param percentiles the percentiles to send. Replaces the default percentiles.
         * @return {@code this}
         */
        public Builder withPercentiles(final Percentile... percentiles) {
            this.percentiles = percentiles;
            return this;
        }

        /**
         * Global {@link Set} of {@link Dimension} to send with each {@link MetricDatum}. A dimension is a name/value
         * pair that helps you to uniquely identify a metric. Every metric has specific characteristics that describe
         * it, and you can think of dimensions as categories for those characteristics.
         * <p>
         * Whenever you add a unique name/value pair to one of your metrics, you are creating a new metric.
         * Defaults to {@code empty} {@link Set}.
         *
         * @param dimensions arguments in a form of {@code name=value}. The number of arguments is variable and may be
         *                   zero. The maximum number of arguments is limited by the maximum dimension of a Java array
         *                   as defined by the Java Virtual Machine Specification. Each {@code name=value} string
         *                   will be converted to an instance of {@link Dimension}
         * @return {@code this}
         */
        public Builder withGlobalDimensions(final String... dimensions) {
            for (final String pair : dimensions) {
                final List<String> splitted = Stream.of(pair.split("=")).map(String::trim).collect(Collectors.toList());
                this.globalDimensions.add(Dimension.builder().name(splitted.get(0)).value(splitted.get(1)).build());
            }
            return this;
        }

        public Builder withHighResolution() {
            this.highResolution = true;
            return this;
        }

        /**
         * Send Meters in other Unit than the DurationUnit. Usefull if the metered metric does not contain timeunits
         * @param reportUnit the Unit which is set as metadata on meter reports.
         * @return {@code this}
         */
        public Builder withMeterUnitSentToCW(final StandardUnit reportUnit) {
            this.cwMeterUnit = Optional.of(reportUnit);
            return this;
        }

        public CloudWatchReporter build() {

            if (withJvmMetrics) {
                metricRegistry.register("jvm.uptime", (Gauge<Long>) () -> ManagementFactory.getRuntimeMXBean().getUptime());
                metricRegistry.register("jvm.current_time", (Gauge<Long>) clock::getTime);
                metricRegistry.register("jvm.classes", new ClassLoadingGaugeSet());
                metricRegistry.register("jvm.fd_usage", new FileDescriptorRatioGauge());
                metricRegistry.register("jvm.buffers", new BufferPoolMetricSet(ManagementFactory.getPlatformMBeanServer()));
                metricRegistry.register("jvm.gc", new GarbageCollectorMetricSet());
                metricRegistry.register("jvm.memory", new MemoryUsageGaugeSet());
                metricRegistry.register("jvm.thread-states", new ThreadStatesGaugeSet());
            }

            cwRateUnit = cwMeterUnit.orElse(toStandardUnit(rateUnit));
            cwDurationUnit = toStandardUnit(durationUnit);

            return new CloudWatchReporter(this);
        }

        private StandardUnit toStandardUnit(final TimeUnit timeUnit) {
            switch (timeUnit) {
                case SECONDS:
                    return StandardUnit.SECONDS;
                case MILLISECONDS:
                    return StandardUnit.MILLISECONDS;
                case MICROSECONDS:
                    return StandardUnit.MICROSECONDS;
                default:
                    throw new IllegalArgumentException("Unsupported TimeUnit: " + timeUnit);
            }
        }
    }
}