// Copyright 2020 New Relic Corporation. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package newrelic

import (
	"bytes"
	"time"

	"github.com/newrelic/go-agent/v3/internal"
	"github.com/newrelic/go-agent/v3/internal/jsonx"
)

type metricForce int

const (
	forced metricForce = iota
	unforced
)

type metricID struct {
	Name  string `json:"name"`
	Scope string `json:"scope,omitempty"`
}

type metricData struct {
	// These values are in the units expected by the collector.
	countSatisfied  float64 // Seconds, or count for Apdex
	totalTolerated  float64 // Seconds, or count for Apdex
	exclusiveFailed float64 // Seconds, or count for Apdex
	min             float64 // Seconds
	max             float64 // Seconds
	sumSquares      float64 // Seconds**2, or 0 for Apdex
}

func metricDataFromDuration(duration, exclusive time.Duration) metricData {
	ds := duration.Seconds()
	return metricData{
		countSatisfied:  1,
		totalTolerated:  ds,
		exclusiveFailed: exclusive.Seconds(),
		min:             ds,
		max:             ds,
		sumSquares:      ds * ds,
	}
}

type metric struct {
	forced metricForce
	data   metricData
}

type metricTable struct {
	metricPeriodStart time.Time
	failedHarvests    int
	maxTableSize      int // After this max is reached, only forced metrics are added
	metrics           map[metricID]*metric
}

func newMetricTable(maxTableSize int, now time.Time) *metricTable {
	return &metricTable{
		metricPeriodStart: now,
		metrics:           make(map[metricID]*metric),
		maxTableSize:      maxTableSize,
		failedHarvests:    0,
	}
}

func (mt *metricTable) full() bool {
	return len(mt.metrics) >= mt.maxTableSize
}

func (data *metricData) aggregate(src metricData) {
	data.countSatisfied += src.countSatisfied
	data.totalTolerated += src.totalTolerated
	data.exclusiveFailed += src.exclusiveFailed

	if src.min < data.min {
		data.min = src.min
	}
	if src.max > data.max {
		data.max = src.max
	}

	data.sumSquares += src.sumSquares
}

func (mt *metricTable) mergeMetric(id metricID, m metric) {
	if to := mt.metrics[id]; nil != to {
		to.data.aggregate(m.data)
		return
	}

	if mt.full() && (unforced == m.forced) {
		mt.addSingleCount(supportabilityDropped, forced)
		return
	}
	// NOTE: `new` is used in place of `&m` since the latter will make `m`
	// get heap allocated regardless of whether or not this line gets
	// reached (running go version go1.5 darwin/amd64).  See
	// BenchmarkAddingSameMetrics.
	alloc := new(metric)
	*alloc = m
	mt.metrics[id] = alloc
}

func (mt *metricTable) mergeFailed(from *metricTable) {
	fails := from.failedHarvests + 1
	if fails >= failedMetricAttemptsLimit {
		return
	}
	if from.metricPeriodStart.Before(mt.metricPeriodStart) {
		mt.metricPeriodStart = from.metricPeriodStart
	}
	mt.failedHarvests = fails
	mt.merge(from, "")
}

func (mt *metricTable) merge(from *metricTable, newScope string) {
	if newScope == "" {
		for id, m := range from.metrics {
			mt.mergeMetric(id, *m)
		}
	} else {
		for id, m := range from.metrics {
			mt.mergeMetric(metricID{Name: id.Name, Scope: newScope}, *m)
		}
	}
}

func (mt *metricTable) add(name, scope string, data metricData, force metricForce) {
	mt.mergeMetric(metricID{Name: name, Scope: scope}, metric{data: data, forced: force})
}

func (mt *metricTable) addCount(name string, count float64, force metricForce) {
	mt.add(name, "", metricData{countSatisfied: count}, force)
}

func (mt *metricTable) addSingleCount(name string, force metricForce) {
	mt.addCount(name, float64(1), force)
}

func (mt *metricTable) addDuration(name, scope string, duration, exclusive time.Duration, force metricForce) {
	mt.add(name, scope, metricDataFromDuration(duration, exclusive), force)
}

func (mt *metricTable) addValueExclusive(name, scope string, total, exclusive float64, force metricForce) {
	data := metricData{
		countSatisfied:  1,
		totalTolerated:  total,
		exclusiveFailed: exclusive,
		min:             total,
		max:             total,
		sumSquares:      total * total,
	}
	mt.add(name, scope, data, force)
}

func (mt *metricTable) addValue(name, scope string, total float64, force metricForce) {
	mt.addValueExclusive(name, scope, total, total, force)
}

func (mt *metricTable) addApdex(name, scope string, apdexThreshold time.Duration, zone apdexZone, force metricForce) {
	apdexSeconds := apdexThreshold.Seconds()
	data := metricData{min: apdexSeconds, max: apdexSeconds}

	switch zone {
	case apdexSatisfying:
		data.countSatisfied = 1
	case apdexTolerating:
		data.totalTolerated = 1
	case apdexFailing:
		data.exclusiveFailed = 1
	}

	mt.add(name, scope, data, force)
}

func (mt *metricTable) CollectorJSON(agentRunID string, now time.Time) ([]byte, error) {
	if 0 == len(mt.metrics) {
		return nil, nil
	}
	estimatedBytesPerMetric := 128
	estimatedLen := len(mt.metrics) * estimatedBytesPerMetric
	buf := bytes.NewBuffer(make([]byte, 0, estimatedLen))
	buf.WriteByte('[')

	jsonx.AppendString(buf, agentRunID)
	buf.WriteByte(',')
	jsonx.AppendInt(buf, mt.metricPeriodStart.Unix())
	buf.WriteByte(',')
	jsonx.AppendInt(buf, now.Unix())
	buf.WriteByte(',')

	buf.WriteByte('[')
	first := true
	for id, metric := range mt.metrics {
		if first {
			first = false
		} else {
			buf.WriteByte(',')
		}
		buf.WriteByte('[')
		buf.WriteByte('{')
		buf.WriteString(`"name":`)
		jsonx.AppendString(buf, id.Name)
		if id.Scope != "" {
			buf.WriteString(`,"scope":`)
			jsonx.AppendString(buf, id.Scope)
		}
		buf.WriteByte('}')
		buf.WriteByte(',')

		jsonx.AppendFloatArray(buf,
			metric.data.countSatisfied,
			metric.data.totalTolerated,
			metric.data.exclusiveFailed,
			metric.data.min,
			metric.data.max,
			metric.data.sumSquares)

		buf.WriteByte(']')
	}
	buf.WriteByte(']')

	buf.WriteByte(']')
	return buf.Bytes(), nil
}

func (mt *metricTable) Data(agentRunID string, harvestStart time.Time) ([]byte, error) {
	return mt.CollectorJSON(agentRunID, harvestStart)
}
func (mt *metricTable) MergeIntoHarvest(h *harvest) {
	h.Metrics.mergeFailed(mt)
}

func (mt *metricTable) ApplyRules(rules internal.MetricRules) *metricTable {
	if nil == rules {
		return mt
	}
	if len(rules) == 0 {
		return mt
	}

	applied := newMetricTable(mt.maxTableSize, mt.metricPeriodStart)
	cache := make(map[string]string)

	for id, m := range mt.metrics {
		out, ok := cache[id.Name]
		if !ok {
			out = rules.Apply(id.Name)
			cache[id.Name] = out
		}

		if "" != out {
			applied.mergeMetric(metricID{Name: out, Scope: id.Scope}, *m)
		}
	}

	return applied
}

func (mt *metricTable) EndpointMethod() string {
	return cmdMetrics
}
