// Copyright 2017 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 promlint provides a linter for Prometheus metrics.
package promlint

import (
	"fmt"
	"io"
	"sort"
	"strings"

	dto "github.com/prometheus/client_model/go"
	"github.com/prometheus/common/expfmt"
)

// A Linter is a Prometheus metrics linter.  It identifies issues with metric
// names, types, and metadata, and reports them to the caller.
type Linter struct {
	r io.Reader
}

// A Problem is an issue detected by a Linter.
type Problem struct {
	// The name of the metric indicated by this Problem.
	Metric string

	// A description of the issue for this Problem.
	Text string
}

// problems is a slice of Problems with a helper method to easily append
// additional Problems to the slice.
type problems []Problem

// Add appends a new Problem to the slice for the specified metric, with
// the specified issue text.
func (p *problems) Add(mf dto.MetricFamily, text string) {
	*p = append(*p, Problem{
		Metric: mf.GetName(),
		Text:   text,
	})
}

// New creates a new Linter that reads an input stream of Prometheus metrics.
// Only the text exposition format is supported.
func New(r io.Reader) *Linter {
	return &Linter{
		r: r,
	}
}

// Lint performs a linting pass, returning a slice of Problems indicating any
// issues found in the metrics stream.  The slice is sorted by metric name
// and issue description.
func (l *Linter) Lint() ([]Problem, error) {
	// TODO(mdlayher): support for protobuf exposition format?
	d := expfmt.NewDecoder(l.r, expfmt.FmtText)

	var problems []Problem

	var mf dto.MetricFamily
	for {
		if err := d.Decode(&mf); err != nil {
			if err == io.EOF {
				break
			}

			return nil, err
		}

		problems = append(problems, lint(mf)...)
	}

	// Ensure deterministic output.
	sort.SliceStable(problems, func(i, j int) bool {
		if problems[i].Metric < problems[j].Metric {
			return true
		}

		return problems[i].Text < problems[j].Text
	})

	return problems, nil
}

// lint is the entry point for linting a single metric.
func lint(mf dto.MetricFamily) []Problem {
	fns := []func(mf dto.MetricFamily) []Problem{
		lintHelp,
		lintMetricUnits,
		lintCounter,
		lintHistogramSummaryReserved,
	}

	var problems []Problem
	for _, fn := range fns {
		problems = append(problems, fn(mf)...)
	}

	// TODO(mdlayher): lint rules for specific metrics types.
	return problems
}

// lintHelp detects issues related to the help text for a metric.
func lintHelp(mf dto.MetricFamily) []Problem {
	var problems problems

	// Expect all metrics to have help text available.
	if mf.Help == nil {
		problems.Add(mf, "no help text")
	}

	return problems
}

// lintMetricUnits detects issues with metric unit names.
func lintMetricUnits(mf dto.MetricFamily) []Problem {
	var problems problems

	unit, base, ok := metricUnits(*mf.Name)
	if !ok {
		// No known units detected.
		return nil
	}

	// Unit is already a base unit.
	if unit == base {
		return nil
	}

	problems.Add(mf, fmt.Sprintf("use base unit %q instead of %q", base, unit))

	return problems
}

// lintCounter detects issues specific to counters, as well as patterns that should
// only be used with counters.
func lintCounter(mf dto.MetricFamily) []Problem {
	var problems problems

	isCounter := mf.GetType() == dto.MetricType_COUNTER
	isUntyped := mf.GetType() == dto.MetricType_UNTYPED
	hasTotalSuffix := strings.HasSuffix(mf.GetName(), "_total")

	switch {
	case isCounter && !hasTotalSuffix:
		problems.Add(mf, `counter metrics should have "_total" suffix`)
	case !isUntyped && !isCounter && hasTotalSuffix:
		problems.Add(mf, `non-counter metrics should not have "_total" suffix`)
	}

	return problems
}

// lintHistogramSummaryReserved detects when other types of metrics use names or labels
// reserved for use by histograms and/or summaries.
func lintHistogramSummaryReserved(mf dto.MetricFamily) []Problem {
	// These rules do not apply to untyped metrics.
	t := mf.GetType()
	if t == dto.MetricType_UNTYPED {
		return nil
	}

	var problems problems

	isHistogram := t == dto.MetricType_HISTOGRAM
	isSummary := t == dto.MetricType_SUMMARY

	n := mf.GetName()

	if !isHistogram && strings.HasSuffix(n, "_bucket") {
		problems.Add(mf, `non-histogram metrics should not have "_bucket" suffix`)
	}
	if !isHistogram && !isSummary && strings.HasSuffix(n, "_count") {
		problems.Add(mf, `non-histogram and non-summary metrics should not have "_count" suffix`)
	}
	if !isHistogram && !isSummary && strings.HasSuffix(n, "_sum") {
		problems.Add(mf, `non-histogram and non-summary metrics should not have "_sum" suffix`)
	}

	for _, m := range mf.GetMetric() {
		for _, l := range m.GetLabel() {
			ln := l.GetName()

			if !isHistogram && ln == "le" {
				problems.Add(mf, `non-histogram metrics should not have "le" label`)
			}
			if !isSummary && ln == "quantile" {
				problems.Add(mf, `non-summary metrics should not have "quantile" label`)
			}
		}
	}

	return problems
}

// metricUnits attempts to detect known unit types used as part of a metric name,
// e.g. "foo_bytes_total" or "bar_baz_milligrams".
func metricUnits(m string) (unit string, base string, ok bool) {
	ss := strings.Split(m, "_")

	for _, u := range baseUnits {
		// Also check for "no prefix".
		for _, p := range append(unitPrefixes, "") {
			for _, s := range ss {
				// Attempt to explicitly match a known unit with a known prefix,
				// as some words may look like "units" when matching suffix.
				//
				// As an example, "thermometers" should not match "meters", but
				// "kilometers" should.
				if s == p+u {
					return p + u, u, true
				}
			}
		}
	}

	return "", "", false
}

// Units and their possible prefixes recognized by this library.  More can be
// added over time as needed.
var (
	baseUnits = []string{
		"amperes",
		"bytes",
		"candela",
		"grams",
		"kelvin", // Both plural and non-plural form allowed.
		"kelvins",
		"meters", // Both American and international spelling permitted.
		"metres",
		"moles",
		"seconds",
	}

	unitPrefixes = []string{
		"pico",
		"nano",
		"micro",
		"milli",
		"centi",
		"deci",
		"deca",
		"hecto",
		"kilo",
		"kibi",
		"mega",
		"mibi",
		"giga",
		"gibi",
		"tera",
		"tebi",
		"peta",
		"pebi",
	}
)