/*
 * 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.time.Duration;
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.TreeMap;
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.SimpleTimer;
import org.eclipse.microprofile.metrics.Snapshot;
import org.eclipse.microprofile.metrics.Timer;

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

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

    // 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 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() {
        try {
            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();
        } catch (IllegalStateException | ExceptionInInitializerError | NoClassDefFoundError t) {
            // MP Config implementation is probably not available. Resort to default configuration.
            usePrefixForScope = true;
            writeHelpLine = true;
        }

    }

    @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, new TreeMap<>(metricMap));
    }

    private void exposeEntries(MetricRegistry.Type scope, StringBuilder sb, MetricRegistry registry,
            Map<MetricID, Metric> metricMap) {
        Map<String, Metadata> metadataMap = registry.getMetadata();
        for (Map.Entry<MetricID, Metric> entry : metricMap.entrySet()) {
            String key = entry.getKey().getName();
            Metadata md = metadataMap.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: {
                        String unitSuffix = null;
                        String unit;
                        String keyOverride = getOpenMetricsKeyOverride(md);
                        if (keyOverride != null) {
                            key = keyOverride;
                        } else {
                            key = getOpenMetricsMetricName(key);
                            unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.unit());
                            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:
                        String suffix;

                        String keyOverride = getOpenMetricsKeyOverride(md);
                        if (keyOverride != null) {
                            key = keyOverride;
                            suffix = null;
                        } else {
                            key = getOpenMetricsMetricName(key);
                            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;
                    case SIMPLE_TIMER:
                        SimpleTimer simpleTimer = (SimpleTimer) metric;
                        writeSimpleTimerValues(metricBuf, scope, simpleTimer, md, tagsMap);
                        break;
                    default:
                        throw new IllegalArgumentException("Not supported: " + key);
                }
                sb.append(metricBuf);
                alreadyExportedNames.get().add(md.getName());
            } catch (Exception e) {
                SmallRyeMetricsLogging.log.unableToExport(key, e);
            }
        }
    }

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

        String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.unit());
        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);
        writeTypeAndValue(sb, scope, "_elapsedTime" + theUnit, timer.getElapsedTime().toNanos(), GAUGE, md, true, tags);

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

    private void writeSimpleTimerValues(StringBuilder sb, MetricRegistry.Type scope, SimpleTimer simpleTimer, Metadata md,
            Map<String, String> tags) {
        String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.unit());
        if (unit.equals(NONE))
            unit = "seconds";

        String theUnit = USCORE + unit;

        // 'total' value plus the help line
        writeHelpLine(sb, scope, md.getName(), md, "_total");
        writeTypeAndValue(sb, scope, "_total", simpleTimer.getCount(), COUNTER, md, false, tags);
        writeTypeAndValue(sb, scope, "_elapsedTime" + theUnit, simpleTimer.getElapsedTime().toNanos(), GAUGE, md, true, tags);
        Duration min = simpleTimer.getMinTimeDuration();
        Duration max = simpleTimer.getMaxTimeDuration();
        if (min != null) {
            writeTypeAndValue(sb, scope, "_minTimeDuration" + theUnit, min.toNanos(), GAUGE, md, true, tags);
        } else {
            writeTypeAndValue(sb, scope, "_minTimeDuration" + theUnit, Double.NaN, GAUGE, md, true, tags);
        }
        if (max != null) {
            writeTypeAndValue(sb, scope, "_maxTimeDuration" + theUnit, max.toNanos(), GAUGE, md, true, tags);
        } else {
            writeTypeAndValue(sb, scope, "_maxTimeDuration" + theUnit, Double.NaN, GAUGE, md, 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.unit();
        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.unit().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 (writeScopeInTag(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 (writeScopeInTag(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(final StringBuilder sb, MetricRegistry.Type scope, String key, Metadata md, String suffix) {
        // Only write this line if we actually have a description in metadata
        Optional<String> description = md.description();
        if (writeHelpLine && description.filter(s -> !s.isEmpty()).isPresent()
                && !alreadyExportedNames.get().contains(md.getName())) {
            sb.append("# HELP ");
            getNameWithScopeAndSuffix(sb, scope, key, suffix, md);
            sb.append(quoteHelpText(description.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);
        // append the base unit only in case that the key wasn't overridden
        if (getOpenMetricsKeyOverride(md) == null) {
            String unit = OpenMetricsUnit.getBaseUnitAsOpenMetricsString(md.unit());
            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 {
                valIn = Double.NaN;
            }
        } else {
            valIn = (double) ((Counter) metric).getCount();
        }

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

    }

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

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

    private boolean writeScopeInTag(Metadata metadata) {
        if (metadata instanceof ExtendedMetadata) {
            ExtendedMetadata extendedMetadata = (ExtendedMetadata) metadata;
            if (extendedMetadata.isSkipsScopeInOpenMetricsExportCompletely())
                return false;
            if (extendedMetadata.prependsScopeToOpenMetricsName().isPresent())
                return !extendedMetadata.prependsScopeToOpenMetricsName().get();
        }
        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 newline characters with a literal \n
                .replaceAll("\\n", "\\\\n")
                // replace \ with \\, unless it is followed by n (which means it is an already escaped newline character from the previous step)
                .replaceAll("\\\\([^n])", "\\\\\\\\$1")
                // replace " with \"
                .replaceAll("\"", "\\\\\"")
                // replace \ at the end of the value with \\
                .replaceAll("\\\\$", "\\\\\\\\");
    }

    private static String getOpenMetricsKeyOverride(Metadata md) {
        if (md instanceof ExtendedMetadata && ((ExtendedMetadata) md).getOpenMetricsKeyOverride().isPresent()) {
            return ((ExtendedMetadata) md).getOpenMetricsKeyOverride().get();
        } else {
            return null;
        }
    }

}