/**
 * Copyright 2017 VMware, Inc.
 * <p>
 * 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
 * <p>
 * https://www.apache.org/licenses/LICENSE-2.0
 * <p>
 * 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 io.micrometer.dynatrace;

import io.micrometer.core.instrument.*;
import io.micrometer.core.instrument.distribution.HistogramSnapshot;
import io.micrometer.core.instrument.step.StepMeterRegistry;
import io.micrometer.core.instrument.util.MeterPartition;
import io.micrometer.core.instrument.util.NamedThreadFactory;
import io.micrometer.core.instrument.util.StringUtils;
import io.micrometer.core.ipc.http.HttpSender;
import io.micrometer.core.ipc.http.HttpUrlConnectionSender;
import io.micrometer.core.lang.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import static io.micrometer.dynatrace.DynatraceMetricDefinition.DynatraceUnit;
import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * {@link StepMeterRegistry} for Dynatrace.
 *
 * @author Oriol Barcelona
 * @author Jon Schneider
 * @author Johnny Lim
 * @author PJ Fanning
 * @since 1.1.0
 */
public class DynatraceMeterRegistry extends StepMeterRegistry {
    private static final ThreadFactory DEFAULT_THREAD_FACTORY = new NamedThreadFactory("dynatrace-metrics-publisher");
    private static final int MAX_MESSAGE_SIZE = 15360; //max message size in bytes that Dynatrace will accept
    private final Logger logger = LoggerFactory.getLogger(DynatraceMeterRegistry.class);
    private final DynatraceConfig config;
    private final HttpSender httpClient;

    /**
     * Metric names for which we have created the custom metric in the API
     */
    private final Set<String> createdCustomMetrics = ConcurrentHashMap.newKeySet();
    private final String customMetricEndpointTemplate;

    @SuppressWarnings("deprecation")
    public DynatraceMeterRegistry(DynatraceConfig config, Clock clock) {
        this(config, clock, DEFAULT_THREAD_FACTORY, new HttpUrlConnectionSender(config.connectTimeout(), config.readTimeout()));
    }

    private DynatraceMeterRegistry(DynatraceConfig config, Clock clock, ThreadFactory threadFactory, HttpSender httpClient) {
        super(config, clock);

        this.config = config;
        this.httpClient = httpClient;

        config().namingConvention(new DynatraceNamingConvention());

        this.customMetricEndpointTemplate = config.uri() + "/api/v1/timeseries/";

        start(threadFactory);
    }

    public static Builder builder(DynatraceConfig config) {
        return new Builder(config);
    }

    @Override
    protected void publish() {
        String customDeviceMetricEndpoint = config.uri() + "/api/v1/entity/infrastructure/custom/" +
                config.deviceId() + "?api-token=" + config.apiToken();

        for (List<Meter> batch : MeterPartition.partition(this, config.batchSize())) {
            final List<DynatraceCustomMetric> series = batch.stream()
                    .flatMap(meter -> meter.match(
                            this::writeMeter,
                            this::writeMeter,
                            this::writeTimer,
                            this::writeSummary,
                            this::writeLongTaskTimer,
                            this::writeMeter,
                            this::writeMeter,
                            this::writeFunctionTimer,
                            this::writeMeter)
                    )
                    .collect(Collectors.toList());

            // TODO is there a way to batch submissions of multiple metrics?
            series.stream()
                    .map(DynatraceCustomMetric::getMetricDefinition)
                    .filter(this::isCustomMetricNotCreated)
                    .forEach(this::putCustomMetric);

            if (!createdCustomMetrics.isEmpty() && !series.isEmpty()) {
                postCustomMetricValues(
                        config.technologyType(),
                        config.group(),
                        series.stream()
                                .map(DynatraceCustomMetric::getTimeSeries)
                                .filter(this::isCustomMetricCreated)
                                .collect(Collectors.toList()),
                        customDeviceMetricEndpoint);
            }
        }
    }

    // VisibleForTesting
    Stream<DynatraceCustomMetric> writeMeter(Meter meter) {
        final long wallTime = clock.wallTime();
        return StreamSupport.stream(meter.measure().spliterator(), false)
                .map(Measurement::getValue)
                .filter(Double::isFinite)
                .map(value -> createCustomMetric(meter.getId(), wallTime, value));
    }

    private Stream<DynatraceCustomMetric> writeLongTaskTimer(LongTaskTimer longTaskTimer) {
        final long wallTime = clock.wallTime();
        final Meter.Id id = longTaskTimer.getId();
        return Stream.of(
                createCustomMetric(idWithSuffix(id, "activeTasks"), wallTime, longTaskTimer.activeTasks(), DynatraceUnit.Count),
                createCustomMetric(idWithSuffix(id, "count"), wallTime, longTaskTimer.duration(getBaseTimeUnit())));
    }

    private Stream<DynatraceCustomMetric> writeSummary(DistributionSummary summary) {
        final long wallTime = clock.wallTime();
        final Meter.Id id = summary.getId();
        final HistogramSnapshot snapshot = summary.takeSnapshot();

        return Stream.of(
                createCustomMetric(idWithSuffix(id, "sum"), wallTime, snapshot.total(getBaseTimeUnit())),
                createCustomMetric(idWithSuffix(id, "count"), wallTime, snapshot.count(), DynatraceUnit.Count),
                createCustomMetric(idWithSuffix(id, "avg"), wallTime, snapshot.mean(getBaseTimeUnit())),
                createCustomMetric(idWithSuffix(id, "max"), wallTime, snapshot.max(getBaseTimeUnit())));
    }

    private Stream<DynatraceCustomMetric> writeFunctionTimer(FunctionTimer timer) {
        final long wallTime = clock.wallTime();
        final Meter.Id id = timer.getId();

        return Stream.of(
                createCustomMetric(idWithSuffix(id, "count"), wallTime, timer.count(), DynatraceUnit.Count),
                createCustomMetric(idWithSuffix(id, "avg"), wallTime, timer.mean(getBaseTimeUnit())),
                createCustomMetric(idWithSuffix(id, "sum"), wallTime, timer.totalTime(getBaseTimeUnit())));
    }

    private Stream<DynatraceCustomMetric> writeTimer(Timer timer) {
        final long wallTime = clock.wallTime();
        final Meter.Id id = timer.getId();
        final HistogramSnapshot snapshot = timer.takeSnapshot();

        return Stream.of(
                createCustomMetric(idWithSuffix(id, "sum"), wallTime, snapshot.total(getBaseTimeUnit())),
                createCustomMetric(idWithSuffix(id, "count"), wallTime, snapshot.count(), DynatraceUnit.Count),
                createCustomMetric(idWithSuffix(id, "avg"), wallTime, snapshot.mean(getBaseTimeUnit())),
                createCustomMetric(idWithSuffix(id, "max"), wallTime, snapshot.max(getBaseTimeUnit())));
    }

    private DynatraceCustomMetric createCustomMetric(Meter.Id id, long time, Number value) {
        return createCustomMetric(id, time, value, DynatraceUnit.fromPlural(id.getBaseUnit()));
    }

    private DynatraceCustomMetric createCustomMetric(Meter.Id id, long time, Number value, @Nullable DynatraceUnit unit) {
        final String metricId = getConventionName(id);
        final List<Tag> tags = getConventionTags(id);
        return new DynatraceCustomMetric(
                new DynatraceMetricDefinition(metricId, id.getDescription(), unit, extractDimensions(tags), new String[]{config.technologyType()}, config.group()),
                new DynatraceTimeSeries(metricId, time, value.doubleValue(), extractDimensionValues(tags)));
    }

    private Set<String> extractDimensions(List<Tag> tags) {
        return tags.stream().map(Tag::getKey).collect(Collectors.toSet());
    }

    private Map<String, String> extractDimensionValues(List<Tag> tags) {
        return tags.stream().collect(Collectors.toMap(Tag::getKey, Tag::getValue));
    }

    private boolean isCustomMetricNotCreated(final DynatraceMetricDefinition metric) {
        return !createdCustomMetrics.contains(metric.getMetricId());
    }

    private boolean isCustomMetricCreated(final DynatraceTimeSeries timeSeries) {
        return createdCustomMetrics.contains(timeSeries.getMetricId());
    }

    // VisibleForTesting
    void putCustomMetric(final DynatraceMetricDefinition customMetric) {
        try {
            httpClient.put(customMetricEndpointTemplate + customMetric.getMetricId() + "?api-token=" + config.apiToken())
                    .withJsonContent(customMetric.asJson())
                    .send()
                    .onSuccess(response -> {
                        logger.debug("created {} as custom metric in dynatrace", customMetric.getMetricId());
                        createdCustomMetrics.add(customMetric.getMetricId());
                    })
                    .onError(response -> {
                        if (logger.isErrorEnabled()) {
                            logger.error("failed to create custom metric {} in dynatrace: {}", customMetric.getMetricId(),
                                    response.body());
                        }
                    });
        } catch (Throwable e) {
            logger.error("failed to create custom metric in dynatrace: {}", customMetric.getMetricId(), e);
        }
    }

    private void postCustomMetricValues(String type, String group, List<DynatraceTimeSeries> timeSeries, String customDeviceMetricEndpoint) {
        try {
            for (DynatraceBatchedPayload postMessage : createPostMessages(type, group, timeSeries)) {
                httpClient.post(customDeviceMetricEndpoint)
                        .withJsonContent(postMessage.payload)
                        .send()
                        .onSuccess(response -> {
                            if (logger.isDebugEnabled()) {
                                logger.debug("successfully sent {} metrics to Dynatrace ({} bytes).",
                                        postMessage.metricCount, postMessage.payload.getBytes(UTF_8).length);
                            }
                        })
                        .onError(response -> {
                            logger.error("failed to send metrics to dynatrace: {}", response.body());
                            logger.debug("failed metrics payload: {}", postMessage.payload);
                        });
            }
        } catch (Throwable e) {
            logger.error("failed to send metrics to dynatrace", e);
        }
    }

    // VisibleForTesting
    List<DynatraceBatchedPayload> createPostMessages(String type, String group, List<DynatraceTimeSeries> timeSeries) {
        final String header = "{\"type\":\"" + type + '\"'
                + (StringUtils.isNotBlank(group) ? ",\"group\":\"" + group + '\"' : "")
                + ",\"series\":[";
        final String footer = "]}";
        final int headerFooterBytes = header.getBytes(UTF_8).length + footer.getBytes(UTF_8).length;
        final int maxMessageSize = MAX_MESSAGE_SIZE - headerFooterBytes;
        List<DynatraceBatchedPayload> payloadBodies = createPostMessageBodies(timeSeries, maxMessageSize);
        return payloadBodies.stream().map(body -> {
            String message = header + body.payload + footer;
            return new DynatraceBatchedPayload(message, body.metricCount);
        }).collect(Collectors.toList());
    }

    private List<DynatraceBatchedPayload> createPostMessageBodies(List<DynatraceTimeSeries> timeSeries, long maxSize) {
        ArrayList<DynatraceBatchedPayload> messages = new ArrayList<>();
        StringBuilder payload = new StringBuilder();
        int metricCount = 0;
        long totalByteCount = 0;
        for (DynatraceTimeSeries ts : timeSeries) {
            String json = ts.asJson();
            int jsonByteCount = json.getBytes(UTF_8).length;
            if (jsonByteCount > maxSize) {
                logger.debug("Time series data for metric '{}' is too large ({} bytes) to send to Dynatrace.", ts.getMetricId(), jsonByteCount);
                continue;
            }
            if ((payload.length() == 0 && totalByteCount + jsonByteCount > maxSize) ||
                    (payload.length() > 0 && totalByteCount + jsonByteCount + 1 > maxSize)) {
                messages.add(new DynatraceBatchedPayload(payload.toString(), metricCount));
                payload.setLength(0);
                totalByteCount = 0;
                metricCount = 0;
            }
            if (payload.length() > 0) {
                payload.append(',');
                totalByteCount++;
            }
            payload.append(json);
            totalByteCount += jsonByteCount;
            metricCount++;
        }
        if (payload.length() > 0) {
            messages.add(new DynatraceBatchedPayload(payload.toString(), metricCount));
        }
        return messages;
    }

    private Meter.Id idWithSuffix(Meter.Id id, String suffix) {
        return id.withName(id.getName() + "." + suffix);
    }

    @Override
    protected TimeUnit getBaseTimeUnit() {
        return TimeUnit.MILLISECONDS;
    }

    public static class Builder {
        private final DynatraceConfig config;

        private Clock clock = Clock.SYSTEM;
        private ThreadFactory threadFactory = DEFAULT_THREAD_FACTORY;
        private HttpSender httpClient;

        @SuppressWarnings("deprecation")
        Builder(DynatraceConfig config) {
            this.config = config;
            this.httpClient = new HttpUrlConnectionSender(config.connectTimeout(), config.readTimeout());
        }

        public Builder clock(Clock clock) {
            this.clock = clock;
            return this;
        }

        public Builder threadFactory(ThreadFactory threadFactory) {
            this.threadFactory = threadFactory;
            return this;
        }

        public Builder httpClient(HttpSender httpClient) {
            this.httpClient = httpClient;
            return this;
        }

        public DynatraceMeterRegistry build() {
            return new DynatraceMeterRegistry(config, clock, threadFactory, httpClient);
        }
    }

    class DynatraceCustomMetric {
        private final DynatraceMetricDefinition metricDefinition;
        private final DynatraceTimeSeries timeSeries;

        DynatraceCustomMetric(final DynatraceMetricDefinition metricDefinition, final DynatraceTimeSeries timeSeries) {
            this.metricDefinition = metricDefinition;
            this.timeSeries = timeSeries;
        }

        DynatraceMetricDefinition getMetricDefinition() {
            return metricDefinition;
        }

        DynatraceTimeSeries getTimeSeries() {
            return timeSeries;
        }
    }
}