/*
 * Copyright 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 io.smallrye.metrics.exporters;

import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigProvider;
import org.eclipse.microprofile.metrics.ConcurrentGauge;
import org.eclipse.microprofile.metrics.Counter;
import org.eclipse.microprofile.metrics.Gauge;
import org.eclipse.microprofile.metrics.Histogram;
import org.eclipse.microprofile.metrics.Metadata;
import org.eclipse.microprofile.metrics.Metered;
import org.eclipse.microprofile.metrics.Metric;
import org.eclipse.microprofile.metrics.MetricID;
import org.eclipse.microprofile.metrics.MetricRegistry;
import org.eclipse.microprofile.metrics.MetricType;
import org.eclipse.microprofile.metrics.Snapshot;
import org.eclipse.microprofile.metrics.Timer;
import org.jboss.logging.Logger;

import io.smallrye.metrics.ExtendedMetadata;
import io.smallrye.metrics.MetricRegistries;

/**
 * Export data in OpenMetrics text format
 *
 * @author Heiko W. Rupp
 */
public class OpenMetricsExporter implements Exporter {

    private static final Logger log = Logger.getLogger("io.smallrye.metrics");

    // This allows to suppress the (noisy) # HELP line
    private static final String MICROPROFILE_METRICS_OMIT_HELP_LINE = "microprofile.metrics.omitHelpLine";
    // Use a prefix to provide the MicroProfile Metrics scope. If false, the scope will be added to the metrics tag
    // with the key "microprofile_scope" instead.
    public static final String SMALLRYE_METRICS_USE_PREFIX_FOR_SCOPE = "smallrye.metrics.usePrefixForScope";

    private static final String LF = "\n";
    private static final String GAUGE = "gauge";
    private static final String SPACE = " ";
    private static final String SUMMARY = "summary";
    private static final String USCORE = "_";
    private static final String COUNTER = "counter";
    private static final String QUANTILE = "quantile";
    private static final String NONE = "none";

    private boolean writeHelpLine;
    private final boolean usePrefixForScope;

    // names of metrics for which we have already exported TYPE and HELP lines within one scope
    // this is to prevent writing them multiple times for the same metric name
    // this should be initialized to an empty map during start of an export and cleared after the export is finished
    private ThreadLocal<Set<String>> alreadyExportedNames = new ThreadLocal<>();

    public OpenMetricsExporter() {
        Config config = ConfigProvider.getConfig();
        Optional<Boolean> tmp = config.getOptionalValue(MICROPROFILE_METRICS_OMIT_HELP_LINE, Boolean.class);
        usePrefixForScope = config.getOptionalValue(SMALLRYE_METRICS_USE_PREFIX_FOR_SCOPE, Boolean.class).orElse(true);
        writeHelpLine = !tmp.isPresent() || !tmp.get();
    }

    @Override
    public StringBuilder exportOneScope(MetricRegistry.Type scope) {
        alreadyExportedNames.set(new HashSet<>());
        StringBuilder sb = new StringBuilder();
        getEntriesForScope(scope, sb);
        alreadyExportedNames.set(null);
        return sb;
    }

    @Override
    public StringBuilder exportAllScopes() {
        StringBuilder sb = new StringBuilder();

        for (MetricRegistry.Type scope : MetricRegistry.Type.values()) {
            alreadyExportedNames.set(new HashSet<>());
            getEntriesForScope(scope, sb);
            alreadyExportedNames.set(null);
        }

        return sb;
    }

    @Override
    public StringBuilder exportOneMetric(MetricRegistry.Type scope, MetricID metricID) {
        alreadyExportedNames.set(new HashSet<>());
        MetricRegistry registry = MetricRegistries.get(scope);
        Map<MetricID, Metric> metricMap = registry.getMetrics();

        Metric m = metricMap.get(metricID);

        Map<MetricID, Metric> outMap = new HashMap<>(1);
        outMap.put(metricID, m);

        StringBuilder sb = new StringBuilder();
        exposeEntries(scope, sb, registry, outMap);
        alreadyExportedNames.set(null);
        return sb;
    }

    @Override
    public StringBuilder exportMetricsByName(MetricRegistry.Type scope, String name) {
        alreadyExportedNames.set(new HashSet<>());
        MetricRegistry registry = MetricRegistries.get(scope);
        Map<MetricID, Metric> metricsToExport = registry.getMetrics()
                .entrySet()
                .stream()
                .filter(entry -> entry.getKey().getName().equals(name))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

        StringBuilder sb = new StringBuilder();
        exposeEntries(scope, sb, registry, metricsToExport);
        alreadyExportedNames.set(null);
        return sb;
    }

    @Override
    public String getContentType() {
        return "text/plain";
    }

    private void getEntriesForScope(MetricRegistry.Type scope, StringBuilder sb) {
        MetricRegistry registry = MetricRegistries.get(scope);
        Map<MetricID, Metric> metricMap = registry.getMetrics();

        exposeEntries(scope, sb, registry, metricMap);
    }

    private void exposeEntries(MetricRegistry.Type scope, StringBuilder sb, MetricRegistry registry,
            Map<MetricID, Metric> metricMap) {
        for (Map.Entry<MetricID, Metric> entry : metricMap.entrySet()) {
            String key = entry.getKey().getName();
            Metadata md = registry.getMetadata().get(key);

            if (md == null) {
                throw new IllegalStateException("No entry for " + key + " found");
            }

            Metric metric = entry.getValue();
            final Map<String, String> tagsMap = entry.getKey().getTags();
            StringBuilder metricBuf = new StringBuilder();

            try {
                switch (md.getTypeRaw()) {
                    case GAUGE: {
                        key = getOpenMetricsMetricName(key);
                        String unitSuffix = null;
                        String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.getUnit());
                        if (!unit.equals(NONE)) {
                            unitSuffix = "_" + unit;
                        }
                        writeHelpLine(metricBuf, scope, key, md, unitSuffix);
                        writeTypeLine(metricBuf, scope, key, md, unitSuffix, null);
                        createSimpleValueLine(metricBuf, scope, key, md, metric, null, tagsMap);
                        break;
                    }
                    case COUNTER:
                        key = getOpenMetricsMetricName(key);
                        String suffix = key.endsWith("_total") ? null : "_total";
                        writeHelpLine(metricBuf, scope, key, md, suffix);
                        writeTypeLine(metricBuf, scope, key, md, suffix, null);
                        createSimpleValueLine(metricBuf, scope, key, md, metric, suffix, tagsMap);
                        break;
                    case CONCURRENT_GAUGE:
                        ConcurrentGauge concurrentGauge = (ConcurrentGauge) metric;
                        writeConcurrentGaugeValues(sb, scope, concurrentGauge, md, key, tagsMap);
                        break;
                    case METERED:
                        Metered meter = (Metered) metric;
                        writeMeterValues(metricBuf, scope, meter, md, tagsMap);
                        break;
                    case TIMER:
                        Timer timer = (Timer) metric;
                        writeTimerValues(metricBuf, scope, timer, md, tagsMap);
                        break;
                    case HISTOGRAM:
                        Histogram histogram = (Histogram) metric;
                        writeHistogramValues(metricBuf, scope, histogram, md, tagsMap);
                        break;
                    default:
                        throw new IllegalArgumentException("Not supported: " + key);
                }
                sb.append(metricBuf);
                alreadyExportedNames.get().add(key);
            } catch (Exception e) {
                log.warn("Unable to export metric " + key, e);
            }
        }
    }

    private void writeTimerValues(StringBuilder sb, MetricRegistry.Type scope, Timer timer, Metadata md,
            Map<String, String> tags) {

        String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.getUnit());
        if (unit.equals(NONE))
            unit = "seconds";

        String theUnit = USCORE + unit;

        writeMeterRateValues(sb, scope, timer, md, tags);
        Snapshot snapshot = timer.getSnapshot();
        writeSnapshotBasics(sb, scope, md, snapshot, theUnit, true, tags);

        writeHelpLine(sb, scope, md.getName(), md, theUnit);
        writeTypeLine(sb, scope, md.getName(), md, theUnit, SUMMARY);
        writeValueLine(sb, scope, theUnit + "_count", timer.getCount(), md, tags, false);

        writeSnapshotQuantiles(sb, scope, md, snapshot, theUnit, true, tags);
    }

    private void writeConcurrentGaugeValues(StringBuilder sb, MetricRegistry.Type scope, ConcurrentGauge concurrentGauge,
            Metadata md, String key, Map<String, String> tags) {
        key = getOpenMetricsMetricName(key);
        writeHelpLine(sb, scope, key, md, "_current");
        writeTypeAndValue(sb, scope, "_current", concurrentGauge.getCount(), GAUGE, md, false, tags);
        writeTypeAndValue(sb, scope, "_max", concurrentGauge.getMax(), GAUGE, md, false, tags);
        writeTypeAndValue(sb, scope, "_min", concurrentGauge.getMin(), GAUGE, md, false, tags);
    }

    private void writeHistogramValues(StringBuilder sb, MetricRegistry.Type scope, Histogram histogram, Metadata md,
            Map<String, String> tags) {

        Snapshot snapshot = histogram.getSnapshot();
        Optional<String> optUnit = md.getUnit();
        String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(optUnit);

        String theUnit = unit.equals("none") ? "" : USCORE + unit;

        writeHelpLine(sb, scope, md.getName(), md, theUnit);
        writeSnapshotBasics(sb, scope, md, snapshot, theUnit, true, tags);
        writeTypeLine(sb, scope, md.getName(), md, theUnit, SUMMARY);
        writeValueLine(sb, scope, theUnit + "_count", histogram.getCount(), md, tags, false);
        writeSnapshotQuantiles(sb, scope, md, snapshot, theUnit, true, tags);
    }

    private void writeSnapshotBasics(StringBuilder sb, MetricRegistry.Type scope, Metadata md, Snapshot snapshot, String unit,
            boolean performScaling, Map<String, String> tags) {

        writeTypeAndValue(sb, scope, "_min" + unit, snapshot.getMin(), GAUGE, md, performScaling, tags);
        writeTypeAndValue(sb, scope, "_max" + unit, snapshot.getMax(), GAUGE, md, performScaling, tags);
        writeTypeAndValue(sb, scope, "_mean" + unit, snapshot.getMean(), GAUGE, md, performScaling, tags);
        writeTypeAndValue(sb, scope, "_stddev" + unit, snapshot.getStdDev(), GAUGE, md, performScaling, tags);
    }

    private void writeSnapshotQuantiles(StringBuilder sb, MetricRegistry.Type scope, Metadata md, Snapshot snapshot,
            String unit,
            boolean performScaling, Map<String, String> tags) {
        Map<String, String> map = copyMap(tags);
        map.put(QUANTILE, "0.5");
        writeValueLine(sb, scope, unit, snapshot.getMedian(), md, map, performScaling);
        map.put(QUANTILE, "0.75");
        writeValueLine(sb, scope, unit, snapshot.get75thPercentile(), md, map, performScaling);
        map.put(QUANTILE, "0.95");
        writeValueLine(sb, scope, unit, snapshot.get95thPercentile(), md, map, performScaling);
        map.put(QUANTILE, "0.98");
        writeValueLine(sb, scope, unit, snapshot.get98thPercentile(), md, map, performScaling);
        map.put(QUANTILE, "0.99");
        writeValueLine(sb, scope, unit, snapshot.get99thPercentile(), md, map, performScaling);
        map.put(QUANTILE, "0.999");
        writeValueLine(sb, scope, unit, snapshot.get999thPercentile(), md, map, performScaling);
    }

    private void writeMeterValues(StringBuilder sb, MetricRegistry.Type scope, Metered metric, Metadata md,
            Map<String, String> tags) {
        writeHelpLine(sb, scope, md.getName(), md, "_total");
        writeTypeAndValue(sb, scope, "_total", metric.getCount(), COUNTER, md, false, tags);
        writeMeterRateValues(sb, scope, metric, md, tags);
    }

    private void writeMeterRateValues(StringBuilder sb, MetricRegistry.Type scope, Metered metric, Metadata md,
            Map<String, String> tags) {
        writeTypeAndValue(sb, scope, "_rate_per_second", metric.getMeanRate(), GAUGE, md, false, tags);
        writeTypeAndValue(sb, scope, "_one_min_rate_per_second", metric.getOneMinuteRate(), GAUGE, md, false, tags);
        writeTypeAndValue(sb, scope, "_five_min_rate_per_second", metric.getFiveMinuteRate(), GAUGE, md, false, tags);
        writeTypeAndValue(sb, scope, "_fifteen_min_rate_per_second", metric.getFifteenMinuteRate(), GAUGE, md, false, tags);
    }

    private void writeTypeAndValue(StringBuilder sb, MetricRegistry.Type scope, String suffix, double valueRaw, String type,
            Metadata md, boolean performScaling, Map<String, String> tags) {
        String key = md.getName();
        writeTypeLine(sb, scope, key, md, suffix, type);
        writeValueLine(sb, scope, suffix, valueRaw, md, tags, performScaling);
    }

    private void writeValueLine(StringBuilder sb, MetricRegistry.Type scope, String suffix, double valueRaw, Metadata md) {
        writeValueLine(sb, scope, suffix, valueRaw, md, null);
    }

    private void writeValueLine(StringBuilder sb, MetricRegistry.Type scope, String suffix, double valueRaw, Metadata md,
            Map<String, String> tags) {
        writeValueLine(sb, scope, suffix, valueRaw, md, tags, true);
    }

    private void writeValueLine(StringBuilder sb,
            MetricRegistry.Type scope,
            String suffix,
            double valueRaw,
            Metadata md,
            Map<String, String> tags,
            boolean performScaling) {
        String name = md.getName();
        name = getOpenMetricsMetricName(name);
        fillBaseName(sb, scope, name, suffix, md);

        // add tags

        if (tags != null) {
            addTags(sb, tags, scope, md);
        }

        sb.append(SPACE);

        Double value;
        if (performScaling) {
            String scaleFrom = "nanoseconds";
            if (md.getTypeRaw() == MetricType.HISTOGRAM)
                // for histograms, internally the data is stored using the metric's unit
                scaleFrom = md.getUnit().orElse(NONE);
            value = OpenMetricsUnit.scaleToBase(scaleFrom, valueRaw);
        } else {
            value = valueRaw;
        }
        sb.append(value).append(LF);

    }

    private void addTags(StringBuilder sb, Map<String, String> tags, MetricRegistry.Type scope, Metadata metadata) {
        if (tags == null || tags.isEmpty()) {
            // always add the microprofile_scope even if there are no other tags
            if (!writeScopeInPrefix(metadata)) {
                sb.append("{microprofile_scope=\"" + scope.getName().toLowerCase() + "\"}");
            }
            return;
        } else {
            Iterator<Map.Entry<String, String>> iter = tags.entrySet().iterator();
            sb.append("{");
            while (iter.hasNext()) {
                Map.Entry<String, String> tag = iter.next();
                sb.append(tag.getKey()).append("=\"").append(quoteValue(tag.getValue())).append("\"");
                if (iter.hasNext()) {
                    sb.append(",");
                }
            }
            // append the microprofile_scope after other tags
            if (!writeScopeInPrefix(metadata)) {
                sb.append(",microprofile_scope=\"" + scope.getName().toLowerCase() + "\"");
            }

            sb.append("}");
        }
    }

    private <K, V> Map<K, V> copyMap(Map<K, V> map) {
        return map.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

    private void fillBaseName(StringBuilder sb, MetricRegistry.Type scope, String key, String suffix, Metadata metadata) {
        if (writeScopeInPrefix(metadata)) {
            sb.append(scope.getName().toLowerCase()).append("_");
        }
        sb.append(key);
        if (suffix != null)
            sb.append(suffix);
    }

    private void writeHelpLine(StringBuilder sb, MetricRegistry.Type scope, String key, Metadata md, String suffix) {
        // Only write this line if we actually have a description in metadata
        if (writeHelpLine && !md.getDescription().orElse("").isEmpty() && !alreadyExportedNames.get().contains(md.getName())) {
            sb.append("# HELP ");
            getNameWithScopeAndSuffix(sb, scope, key, suffix, md);
            sb.append(quoteHelpText(md.getDescription().get()));
            sb.append(LF);
        }

    }

    private void writeTypeLine(StringBuilder sb, MetricRegistry.Type scope, String key, Metadata md, String suffix,
            String typeOverride) {
        if (!alreadyExportedNames.get().contains(md.getName())) {
            sb.append("# TYPE ");
            getNameWithScopeAndSuffix(sb, scope, key, suffix, md);
            if (typeOverride != null) {
                sb.append(typeOverride);
            } else if (md.getTypeRaw().equals(MetricType.TIMER)) {
                sb.append(SUMMARY);
            } else if (md.getTypeRaw().equals(MetricType.METERED)) {
                sb.append(COUNTER);
            } else {
                sb.append(md.getType());
            }
            sb.append(LF);
        }
    }

    private void getNameWithScopeAndSuffix(StringBuilder sb, MetricRegistry.Type scope, String key, String suffix,
            Metadata metadata) {
        if (writeScopeInPrefix(metadata)) {
            sb.append(scope.getName().toLowerCase()).append('_');
        }
        sb.append(getOpenMetricsMetricName(key));
        if (suffix != null) {
            sb.append(suffix);
        }
        sb.append(SPACE);
    }

    private void createSimpleValueLine(StringBuilder sb, MetricRegistry.Type scope, String key, Metadata md, Metric metric,
            String suffix, Map<String, String> tags) {

        // value line
        fillBaseName(sb, scope, key, suffix, md);
        String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.getUnit());
        if (!unit.equals(NONE)) {
            sb.append(USCORE).append(unit);
        }

        addTags(sb, tags, scope, md);

        double valIn;
        if (md.getTypeRaw().equals(MetricType.GAUGE)) {
            Number value1 = (Number) ((Gauge) metric).getValue();
            if (value1 != null) {
                valIn = value1.doubleValue();
            } else {
                log.warn("Value is null for " + key);
                throw new IllegalStateException("Value must not be null for " + key);
            }
        } else {
            valIn = (double) ((Counter) metric).getCount();
        }

        Double value = OpenMetricsUnit.scaleToBase(md.getUnit().orElse(NONE), valIn);
        sb.append(SPACE).append(value).append(LF);

    }

    static String getOpenMetricsMetricName(String name) {
        String out = name.replaceAll("[^\\w]+", USCORE);
        out = out.replace("__", USCORE);
        out = out.replace(":_", ":");

        return out;
    }

    private boolean writeScopeInPrefix(Metadata metadata) {
        if (metadata instanceof ExtendedMetadata) {
            ExtendedMetadata extendedMetadata = (ExtendedMetadata) metadata;
            return extendedMetadata.prependsScopeToOpenMetricsName().orElse(usePrefixForScope);
        } else {
            return usePrefixForScope;
        }
    }

    public static String quoteHelpText(String value) {
        return value
                // replace \ with \\, unless it is followed by n, in which case it is a newline character, which should not be changed
                .replaceAll("\\\\([^n])", "\\\\\\\\$1")
                // replace \ at the end of the value with \\
                .replaceAll("\\\\$", "\\\\\\\\");
    }

    public static String quoteValue(String value) {
        return value
                // replace \ with \\, unless it is followed by n, in which case it is a newline character, which should not be changed
                .replaceAll("\\\\([^n])", "\\\\\\\\$1")
                // replace " with \"
                .replaceAll("\"", "\\\\\"")
                // replace \ at the end of the value with \\
                .replaceAll("\\\\$", "\\\\\\\\");
    }

}
