// Copyright 2020 The Prometheus Authors
// 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 expfmt

import (
	"bufio"
	"bytes"
	"fmt"
	"io"
	"math"
	"strconv"
	"strings"

	dto "github.com/prometheus/client_model/go"
	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/prometheus/common/model"
)

type encoderOption struct {
	withCreatedLines bool
	withUnit         bool
}

type EncoderOption func(*encoderOption)

// WithCreatedLines is an EncoderOption that configures the OpenMetrics encoder
// to include _created lines (See
// https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#counter-1).
// Created timestamps can improve the accuracy of series reset detection, but
// come with a bandwidth cost.
//
// At the time of writing, created timestamp ingestion is still experimental in
// Prometheus and need to be enabled with the feature-flag
// `--feature-flag=created-timestamp-zero-ingestion`, and breaking changes are
// still possible. Therefore, it is recommended to use this feature with caution.
func WithCreatedLines() EncoderOption {
	return func(t *encoderOption) {
		t.withCreatedLines = true
	}
}

// WithUnit is an EncoderOption enabling a set unit to be written to the output
// and to be added to the metric name, if it's not there already, as a suffix.
// Without opting in this way, the unit will not be added to the metric name and,
// on top of that, the unit will not be passed onto the output, even if it
// were declared in the *dto.MetricFamily struct, i.e. even if in.Unit !=nil.
func WithUnit() EncoderOption {
	return func(t *encoderOption) {
		t.withUnit = true
	}
}

// MetricFamilyToOpenMetrics converts a MetricFamily proto message into the
// OpenMetrics text format and writes the resulting lines to 'out'. It returns
// the number of bytes written and any error encountered. The output will have
// the same order as the input, no further sorting is performed. Furthermore,
// this function assumes the input is already sanitized and does not perform any
// sanity checks. If the input contains duplicate metrics or invalid metric or
// label names, the conversion will result in invalid text format output.
//
// If metric names conform to the legacy validation pattern, they will be placed
// outside the brackets in the traditional way, like `foo{}`. If the metric name
// fails the legacy validation check, it will be placed quoted inside the
// brackets: `{"foo"}`. As stated above, the input is assumed to be santized and
// no error will be thrown in this case.
//
// Similar to metric names, if label names conform to the legacy validation
// pattern, they will be unquoted as normal, like `foo{bar="baz"}`. If the label
// name fails the legacy validation check, it will be quoted:
// `foo{"bar"="baz"}`. As stated above, the input is assumed to be santized and
// no error will be thrown in this case.
//
// This function fulfills the type 'expfmt.encoder'.
//
// Note that OpenMetrics requires a final `# EOF` line. Since this function acts
// on individual metric families, it is the responsibility of the caller to
// append this line to 'out' once all metric families have been written.
// Conveniently, this can be done by calling FinalizeOpenMetrics.
//
// The output should be fully OpenMetrics compliant. However, there are a few
// missing features and peculiarities to avoid complications when switching from
// Prometheus to OpenMetrics or vice versa:
//
//   - Counters are expected to have the `_total` suffix in their metric name. In
//     the output, the suffix will be truncated from the `# TYPE`, `# HELP` and `# UNIT`
//     lines. A counter with a missing `_total` suffix is not an error. However,
//     its type will be set to `unknown` in that case to avoid invalid OpenMetrics
//     output.
//
//   - According to the OM specs, the `# UNIT` line is optional, but if populated,
//     the unit has to be present in the metric name as its suffix:
//     (see https://github.com/prometheus/OpenMetrics/blob/v1.0.0/specification/OpenMetrics.md#unit).
//     However, in order to accommodate any potential scenario where such a change in the
//     metric name is not desirable, the users are here given the choice of either explicitly
//     opt in, in case they wish for the unit to be included in the output AND in the metric name
//     as a suffix (see the description of the WithUnit function above),
//     or not to opt in, in case they don't want for any of that to happen.
//
//   - No support for the following (optional) features: info type,
//     stateset type, gaugehistogram type.
//
//   - The size of exemplar labels is not checked (i.e. it's possible to create
//     exemplars that are larger than allowed by the OpenMetrics specification).
//
//   - The value of Counters is not checked. (OpenMetrics doesn't allow counters
//     with a `NaN` value.)
func MetricFamilyToOpenMetrics(out io.Writer, in *dto.MetricFamily, options ...EncoderOption) (written int, err error) {
	toOM := encoderOption{}
	for _, option := range options {
		option(&toOM)
	}

	name := in.GetName()
	if name == "" {
		return 0, fmt.Errorf("MetricFamily has no name: %s", in)
	}

	// Try the interface upgrade. If it doesn't work, we'll use a
	// bufio.Writer from the sync.Pool.
	w, ok := out.(enhancedWriter)
	if !ok {
		b := bufPool.Get().(*bufio.Writer)
		b.Reset(out)
		w = b
		defer func() {
			bErr := b.Flush()
			if err == nil {
				err = bErr
			}
			bufPool.Put(b)
		}()
	}

	var (
		n             int
		metricType    = in.GetType()
		compliantName = name
	)
	if metricType == dto.MetricType_COUNTER && strings.HasSuffix(compliantName, "_total") {
		compliantName = name[:len(name)-6]
	}
	if toOM.withUnit && in.Unit != nil && !strings.HasSuffix(compliantName, "_"+*in.Unit) {
		compliantName = compliantName + "_" + *in.Unit
	}

	// Comments, first HELP, then TYPE.
	if in.Help != nil {
		n, err = w.WriteString("# HELP ")
		written += n
		if err != nil {
			return written, err
		}
		n, err = writeName(w, compliantName)
		written += n
		if err != nil {
			return written, err
		}
		err = w.WriteByte(' ')
		written++
		if err != nil {
			return written, err
		}
		n, err = writeEscapedString(w, *in.Help, true)
		written += n
		if err != nil {
			return written, err
		}
		err = w.WriteByte('\n')
		written++
		if err != nil {
			return written, err
		}
	}
	n, err = w.WriteString("# TYPE ")
	written += n
	if err != nil {
		return written, err
	}
	n, err = writeName(w, compliantName)
	written += n
	if err != nil {
		return written, err
	}
	switch metricType {
	case dto.MetricType_COUNTER:
		if strings.HasSuffix(name, "_total") {
			n, err = w.WriteString(" counter\n")
		} else {
			n, err = w.WriteString(" unknown\n")
		}
	case dto.MetricType_GAUGE:
		n, err = w.WriteString(" gauge\n")
	case dto.MetricType_SUMMARY:
		n, err = w.WriteString(" summary\n")
	case dto.MetricType_UNTYPED:
		n, err = w.WriteString(" unknown\n")
	case dto.MetricType_HISTOGRAM:
		n, err = w.WriteString(" histogram\n")
	case dto.MetricType_GAUGE_HISTOGRAM:
		n, err = w.WriteString(" gaugehistogram\n")
	default:
		return written, fmt.Errorf("unknown metric type %s", metricType.String())
	}
	written += n
	if err != nil {
		return written, err
	}
	if toOM.withUnit && in.Unit != nil {
		n, err = w.WriteString("# UNIT ")
		written += n
		if err != nil {
			return written, err
		}
		n, err = writeName(w, compliantName)
		written += n
		if err != nil {
			return written, err
		}

		err = w.WriteByte(' ')
		written++
		if err != nil {
			return written, err
		}
		n, err = writeEscapedString(w, *in.Unit, true)
		written += n
		if err != nil {
			return written, err
		}
		err = w.WriteByte('\n')
		written++
		if err != nil {
			return written, err
		}
	}

	var createdTsBytesWritten int

	// Finally the samples, one line for each.
	if metricType == dto.MetricType_COUNTER && strings.HasSuffix(name, "_total") {
		compliantName += "_total"
	}
	for _, metric := range in.Metric {
		switch metricType {
		case dto.MetricType_COUNTER:
			if metric.Counter == nil {
				return written, fmt.Errorf(
					"expected counter in metric %s %s", compliantName, metric,
				)
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "", metric, "", 0,
				metric.Counter.GetValue(), 0, false,
				metric.Counter.Exemplar,
			)
			if toOM.withCreatedLines && metric.Counter.CreatedTimestamp != nil {
				createdTsBytesWritten, err = writeOpenMetricsCreated(w, compliantName, "_total", metric, "", 0, metric.Counter.GetCreatedTimestamp())
				n += createdTsBytesWritten
			}
		case dto.MetricType_GAUGE:
			if metric.Gauge == nil {
				return written, fmt.Errorf(
					"expected gauge in metric %s %s", compliantName, metric,
				)
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "", metric, "", 0,
				metric.Gauge.GetValue(), 0, false,
				nil,
			)
		case dto.MetricType_UNTYPED:
			if metric.Untyped == nil {
				return written, fmt.Errorf(
					"expected untyped in metric %s %s", compliantName, metric,
				)
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "", metric, "", 0,
				metric.Untyped.GetValue(), 0, false,
				nil,
			)
		case dto.MetricType_SUMMARY:
			if metric.Summary == nil {
				return written, fmt.Errorf(
					"expected summary in metric %s %s", compliantName, metric,
				)
			}
			for _, q := range metric.Summary.Quantile {
				n, err = writeOpenMetricsSample(
					w, compliantName, "", metric,
					model.QuantileLabel, q.GetQuantile(),
					q.GetValue(), 0, false,
					nil,
				)
				written += n
				if err != nil {
					return written, err
				}
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "_sum", metric, "", 0,
				metric.Summary.GetSampleSum(), 0, false,
				nil,
			)
			written += n
			if err != nil {
				return written, err
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "_count", metric, "", 0,
				0, metric.Summary.GetSampleCount(), true,
				nil,
			)
			if toOM.withCreatedLines && metric.Summary.CreatedTimestamp != nil {
				createdTsBytesWritten, err = writeOpenMetricsCreated(w, compliantName, "", metric, "", 0, metric.Summary.GetCreatedTimestamp())
				n += createdTsBytesWritten
			}
		case dto.MetricType_HISTOGRAM, dto.MetricType_GAUGE_HISTOGRAM:
			if metric.Histogram == nil {
				return written, fmt.Errorf(
					"expected histogram in metric %s %s", compliantName, metric,
				)
			}
			infSeen := false
			for _, b := range metric.Histogram.Bucket {
				if b.GetCumulativeCountFloat() > 0 {
					return written, fmt.Errorf(
						"OpenMetrics v1.0 does not support float histogram %s %s",
						compliantName, metric,
					)
				}
				n, err = writeOpenMetricsSample(
					w, compliantName, "_bucket", metric,
					model.BucketLabel, b.GetUpperBound(),
					0, b.GetCumulativeCount(), true,
					b.Exemplar,
				)
				written += n
				if err != nil {
					return written, err
				}
				if math.IsInf(b.GetUpperBound(), +1) {
					infSeen = true
				}
			}
			if !infSeen {
				n, err = writeOpenMetricsSample(
					w, compliantName, "_bucket", metric,
					model.BucketLabel, math.Inf(+1),
					0, metric.Histogram.GetSampleCount(), true,
					nil,
				)
				// We do not check for a float sample count here
				// because we will check for it below (and error
				// out if needed).
				written += n
				if err != nil {
					return written, err
				}
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "_sum", metric, "", 0,
				metric.Histogram.GetSampleSum(), 0, false,
				nil,
			)
			written += n
			if err != nil {
				return written, err
			}
			if metric.Histogram.GetSampleCountFloat() > 0 {
				return written, fmt.Errorf(
					"OpenMetrics v1.0 does not support float histogram %s %s",
					compliantName, metric,
				)
			}
			n, err = writeOpenMetricsSample(
				w, compliantName, "_count", metric, "", 0,
				0, metric.Histogram.GetSampleCount(), true,
				nil,
			)
			if toOM.withCreatedLines && metric.Histogram.CreatedTimestamp != nil {
				createdTsBytesWritten, err = writeOpenMetricsCreated(w, compliantName, "", metric, "", 0, metric.Histogram.GetCreatedTimestamp())
				n += createdTsBytesWritten
			}
		default:
			return written, fmt.Errorf(
				"unexpected type in metric %s %s", compliantName, metric,
			)
		}
		written += n
		if err != nil {
			return written, err
		}
	}
	return written, err
}

// FinalizeOpenMetrics writes the final `# EOF\n` line required by OpenMetrics.
func FinalizeOpenMetrics(w io.Writer) (written int, err error) {
	return w.Write([]byte("# EOF\n"))
}

// writeOpenMetricsSample writes a single sample in OpenMetrics text format to
// w, given the metric name, the metric proto message itself, optionally an
// additional label name with a float64 value (use empty string as label name if
// not required), the value (optionally as float64 or uint64, determined by
// useIntValue), and optionally an exemplar (use nil if not required). The
// function returns the number of bytes written and any error encountered.
func writeOpenMetricsSample(
	w enhancedWriter,
	name, suffix string,
	metric *dto.Metric,
	additionalLabelName string, additionalLabelValue float64,
	floatValue float64, intValue uint64, useIntValue bool,
	exemplar *dto.Exemplar,
) (int, error) {
	written := 0
	n, err := writeOpenMetricsNameAndLabelPairs(
		w, name+suffix, metric.Label, additionalLabelName, additionalLabelValue,
	)
	written += n
	if err != nil {
		return written, err
	}
	err = w.WriteByte(' ')
	written++
	if err != nil {
		return written, err
	}
	if useIntValue {
		n, err = writeUint(w, intValue)
	} else {
		n, err = writeOpenMetricsFloat(w, floatValue)
	}
	written += n
	if err != nil {
		return written, err
	}
	if metric.TimestampMs != nil {
		err = w.WriteByte(' ')
		written++
		if err != nil {
			return written, err
		}
		// TODO(beorn7): Format this directly without converting to a float first.
		n, err = writeOpenMetricsFloat(w, float64(*metric.TimestampMs)/1000)
		written += n
		if err != nil {
			return written, err
		}
	}
	if exemplar != nil && len(exemplar.Label) > 0 {
		n, err = writeExemplar(w, exemplar)
		written += n
		if err != nil {
			return written, err
		}
	}
	err = w.WriteByte('\n')
	written++
	if err != nil {
		return written, err
	}
	return written, nil
}

// writeOpenMetricsNameAndLabelPairs works like writeOpenMetricsSample but
// formats the float in OpenMetrics style.
func writeOpenMetricsNameAndLabelPairs(
	w enhancedWriter,
	name string,
	in []*dto.LabelPair,
	additionalLabelName string, additionalLabelValue float64,
) (int, error) {
	var (
		written            int
		separator          byte = '{'
		metricInsideBraces      = false
	)

	if name != "" {
		// If the name does not pass the legacy validity check, we must put the
		// metric name inside the braces, quoted.
		if !model.LegacyValidation.IsValidMetricName(name) {
			metricInsideBraces = true
			err := w.WriteByte(separator)
			written++
			if err != nil {
				return written, err
			}
			separator = ','
		}

		n, err := writeName(w, name)
		written += n
		if err != nil {
			return written, err
		}
	}

	if len(in) == 0 && additionalLabelName == "" {
		if metricInsideBraces {
			err := w.WriteByte('}')
			written++
			if err != nil {
				return written, err
			}
		}
		return written, nil
	}

	for _, lp := range in {
		err := w.WriteByte(separator)
		written++
		if err != nil {
			return written, err
		}
		n, err := writeName(w, lp.GetName())
		written += n
		if err != nil {
			return written, err
		}
		n, err = w.WriteString(`="`)
		written += n
		if err != nil {
			return written, err
		}
		n, err = writeEscapedString(w, lp.GetValue(), true)
		written += n
		if err != nil {
			return written, err
		}
		err = w.WriteByte('"')
		written++
		if err != nil {
			return written, err
		}
		separator = ','
	}
	if additionalLabelName != "" {
		err := w.WriteByte(separator)
		written++
		if err != nil {
			return written, err
		}
		n, err := w.WriteString(additionalLabelName)
		written += n
		if err != nil {
			return written, err
		}
		n, err = w.WriteString(`="`)
		written += n
		if err != nil {
			return written, err
		}
		n, err = writeOpenMetricsFloat(w, additionalLabelValue)
		written += n
		if err != nil {
			return written, err
		}
		err = w.WriteByte('"')
		written++
		if err != nil {
			return written, err
		}
	}
	err := w.WriteByte('}')
	written++
	if err != nil {
		return written, err
	}
	return written, nil
}

// writeOpenMetricsCreated writes the created timestamp for a single time series
// following OpenMetrics text format to w, given the metric name, the metric proto
// message itself, optionally a suffix to be removed, e.g. '_total' for counters,
// an additional label name with a float64 value (use empty string as label name if
// not required) and the timestamp that represents the created timestamp.
// The function returns the number of bytes written and any error encountered.
func writeOpenMetricsCreated(w enhancedWriter,
	name, suffixToTrim string, metric *dto.Metric,
	additionalLabelName string, additionalLabelValue float64,
	createdTimestamp *timestamppb.Timestamp,
) (int, error) {
	written := 0
	n, err := writeOpenMetricsNameAndLabelPairs(
		w, strings.TrimSuffix(name, suffixToTrim)+"_created", metric.Label, additionalLabelName, additionalLabelValue,
	)
	written += n
	if err != nil {
		return written, err
	}

	err = w.WriteByte(' ')
	written++
	if err != nil {
		return written, err
	}

	// TODO(beorn7): Format this directly from components of ts to
	// avoid overflow/underflow and precision issues of the float
	// conversion.
	n, err = writeOpenMetricsFloat(w, float64(createdTimestamp.AsTime().UnixNano())/1e9)
	written += n
	if err != nil {
		return written, err
	}

	err = w.WriteByte('\n')
	written++
	if err != nil {
		return written, err
	}
	return written, nil
}

// writeExemplar writes the provided exemplar in OpenMetrics format to w. The
// function returns the number of bytes written and any error encountered.
func writeExemplar(w enhancedWriter, e *dto.Exemplar) (int, error) {
	written := 0
	n, err := w.WriteString(" # ")
	written += n
	if err != nil {
		return written, err
	}
	n, err = writeOpenMetricsNameAndLabelPairs(w, "", e.Label, "", 0)
	written += n
	if err != nil {
		return written, err
	}
	err = w.WriteByte(' ')
	written++
	if err != nil {
		return written, err
	}
	n, err = writeOpenMetricsFloat(w, e.GetValue())
	written += n
	if err != nil {
		return written, err
	}
	if e.Timestamp != nil {
		err = w.WriteByte(' ')
		written++
		if err != nil {
			return written, err
		}
		err = e.Timestamp.CheckValid()
		if err != nil {
			return written, err
		}
		ts := e.Timestamp.AsTime()
		// TODO(beorn7): Format this directly from components of ts to
		// avoid overflow/underflow and precision issues of the float
		// conversion.
		n, err = writeOpenMetricsFloat(w, float64(ts.UnixNano())/1e9)
		written += n
		if err != nil {
			return written, err
		}
	}
	return written, nil
}

// writeOpenMetricsFloat works like writeFloat but appends ".0" if the resulting
// number would otherwise contain neither a "." nor an "e".
func writeOpenMetricsFloat(w enhancedWriter, f float64) (int, error) {
	switch {
	case f == 1:
		return w.WriteString("1.0")
	case f == 0:
		return w.WriteString("0.0")
	case f == -1:
		return w.WriteString("-1.0")
	case math.IsNaN(f):
		return w.WriteString("NaN")
	case math.IsInf(f, +1):
		return w.WriteString("+Inf")
	case math.IsInf(f, -1):
		return w.WriteString("-Inf")
	default:
		bp := numBufPool.Get().(*[]byte)
		*bp = strconv.AppendFloat((*bp)[:0], f, 'g', -1, 64)
		if !bytes.ContainsAny(*bp, "e.") {
			*bp = append(*bp, '.', '0')
		}
		written, err := w.Write(*bp)
		numBufPool.Put(bp)
		return written, err
	}
}

// writeUint is like writeInt just for uint64.
func writeUint(w enhancedWriter, u uint64) (int, error) {
	bp := numBufPool.Get().(*[]byte)
	*bp = strconv.AppendUint((*bp)[:0], u, 10)
	written, err := w.Write(*bp)
	numBufPool.Put(bp)
	return written, err
}
