package classic

import (
	"context"
	"encoding/json"
	"fmt"
	"strconv"
	"time"

	"github.com/grafana/grafana-plugin-sdk-go/data"

	"github.com/grafana/grafana/pkg/expr/mathexp"
	"github.com/grafana/grafana/pkg/expr/metrics"
	"github.com/grafana/grafana/pkg/infra/tracing"
)

// ConditionsCmd is a command that supports the reduction and comparison of conditions.
//
// A condition in ConditionsCmd can reduce a time series, contain an instant metric, or the
// result of another expression; and checks if it exceeds a threshold, falls within a range,
// or does not contain a value.
//
// If ConditionsCmd contains more than one condition, it reduces the boolean outcomes of the
// threshold, range or value checks using the logical operator of the right hand side condition
// until all conditions have been reduced to a single boolean outcome. ConditionsCmd does not
// follow operator precedence.
//
// For example if we have the following classic condition:
//
//	min(A) > 5 OR max(B) < 10 AND C = 1
//
// which reduces to the following boolean outcomes:
//
//	false OR true AND true
//
// then the outcome of ConditionsCmd is true.
type ConditionsCmd struct {
	Conditions []condition
	RefID      string
}

// condition is a single condition in ConditionsCmd.
type condition struct {
	InputRefID string

	// Reducer reduces a series of data into a single result. An example of a reducer is the avg,
	// min and max functions.
	Reducer reducer

	// Evaluator evaluates the reduced time series, instant metric, or result of another expression
	// against an evaluator. An example of an evaluator is checking if it exceeds a threshold,
	// falls within a range, or does not contain a value.
	Evaluator evaluator

	// Operator is the logical operator to use when there are two conditions in ConditionsCmd.
	// If there are more than two conditions in ConditionsCmd then operator is used to compare
	// the outcome of this condition with that of the condition before it.
	Operator ConditionOperatorType
}

// NeedsVars returns the variable names (refIds) that are dependencies
// to execute the command and allows the command to fulfill the Command interface.
func (cmd *ConditionsCmd) NeedsVars() []string {
	vars := []string{}
	for _, c := range cmd.Conditions {
		vars = append(vars, c.InputRefID)
	}
	return vars
}

// Execute runs the command and returns the results or an error if the command
// failed to execute.
func (cmd *ConditionsCmd) Execute(ctx context.Context, t time.Time, vars mathexp.Vars, tracer tracing.Tracer, _ *metrics.ExprMetrics) (mathexp.Results, error) {
	ctx, span := tracer.Start(ctx, "SSE.ExecuteClassicConditions")
	defer span.End()
	// isFiring and isNoData contains the outcome of ConditionsCmd, and is derived from the
	// boolean comparison of isCondFiring and isCondNoData of all conditions in ConditionsCmd
	var isFiring, isNoData bool

	// matches contains the list of matches for all conditions
	matches := make([]EvalMatch, 0)
	for i, cond := range cmd.Conditions {
		// Avoid operate subsequent conditions for LogicOr when it is already firing, see #87483
		if isFiring && cond.Operator == ConditionOperatorLogicOr {
			break
		}

		isCondFiring, isCondNoData, condMatches, err := cmd.executeCond(ctx, t, cond, vars)
		if err != nil {
			return mathexp.Results{}, err
		}

		if i == 0 {
			// If this condition is the first condition evaluated in ConditionsCmd
			// then isFiring and isNoData must be set to the outcome of the condition
			isFiring = isCondFiring
			isNoData = isCondNoData
		} else {
			// If this is condition is a subsequent condition then isFiring and isNoData
			// must be derived from the boolean comparison of all previous conditions
			// and the current condition
			isFiring = compareWithOperator(isFiring, isCondFiring, cond.Operator)
			isNoData = compareWithOperator(isNoData, isCondNoData, cond.Operator)
		}

		matches = append(matches, condMatches...)
	}

	// Start to prepare the result of the ConditionsCmd. It contains a mathexp.Number
	// that has a value of 1, 0, or nil, depending on whether the result is firing, normal,
	// or no data; and a list of matches for all conditions
	number := mathexp.NewNumber("", nil)
	number.SetMeta(matches)

	var v float64
	// isNoData must be checked first because it is possible for both isNoData and isFiring
	// to be true at the same time
	if isNoData {
		number.SetValue(nil)
	} else if isFiring {
		v = 1
		number.SetValue(&v)
	} else {
		// the default value of v is 0
		number.SetValue(&v)
	}

	res := mathexp.Results{}
	res.Values = append(res.Values, number)
	return res, nil
}

func (cmd *ConditionsCmd) executeCond(_ context.Context, _ time.Time, cond condition, vars mathexp.Vars) (bool, bool, []EvalMatch, error) {
	// isCondFiring and isCondNoData contains the outcome of the condition in ConditionsCmd.
	// The condition is firing if isCondFiring is true, and no data if isCondNoData is true.
	// It should not be possible for both isCondFiring and isCondNoData to be true, however
	// both can be false.
	//
	// There are a number of reasons a condition can have no data:
	//
	//  1. The input data vars[cond.InputRefID] has no values
	//  2. The input data has one or more values, however all are mathexp.NoData
	//  3. The input data has one or more values of mathexp.Number or mathexp.Series, however
	//     either all mathexp.Number have a nil float64 or the reduce function for all mathexp.Series
	//     returns a mathexp.Number with a nil float64
	//  4. The input data is a combination of all mathexp.NoData, mathexp.Number with a nil float64,
	//     or mathexp.Series that reduce to a nil float64
	var isCondFiring, isCondNoData bool
	var numSeriesNoData int

	matches := make([]EvalMatch, 0)
	data := vars[cond.InputRefID]

	if len(data.Values) == 0 {
		// If there are no values, but the condition is checking for no value, then set
		// isCondFiring and add a match with no value.
		if cond.Evaluator.Kind() == EvaluatorNoValue {
			isCondFiring = true
			matches = append(matches, EvalMatch{Value: nil})
			return isCondFiring, isCondNoData, matches, nil
		}
	}

	// Look at all values and compare them against the condition. The values can contain
	// either no data, numbers, or time series.
	for _, value := range data.Values {
		var (
			name   string
			number mathexp.Number
		)
		switch v := value.(type) {
		case mathexp.NoData:
			// Reduce expressions return v.New(), however ConditionsCmds use the operator
			// in the condition to determine if the outcome is no data. To keep this code as
			// simple as possible we translate mathexp.NoData into a mathexp.Number with a
			// nil value so number.GetFloat64Value() returns nil
			number = mathexp.NewNumber("no data", nil)
			number.SetValue(nil)
		case mathexp.Number:
			if len(v.Frame.Fields) > 0 {
				name = v.Frame.Fields[0].Name
			}
			number = v
		case mathexp.Series:
			name = v.GetName()
			number = cond.Reducer.Reduce(v)
		default:
			return false, false, nil, fmt.Errorf("can only reduce type series, got type %v", v.Type())
		}

		isValueFiring := cond.Evaluator.Eval(number)
		// If the value was either a mathexp.NoData, a mathexp.Number with a nil float64,
		// or mathexp.Series that reduced to a nil float64, it is no data
		isValueNoData := number.GetFloat64Value() == nil

		if isValueFiring {
			isCondFiring = true
			// If the condition is met then add it to the list of matching conditions
			labels := number.GetLabels()
			if labels != nil {
				labels = labels.Copy()
			}
			matches = append(matches, EvalMatch{
				Metric: name,
				Value:  number.GetFloat64Value(),
				Labels: labels,
			})
		} else if isValueNoData {
			numSeriesNoData += 1
		}
	}

	// The condition is no data iff all the input data is a combination of all mathexp.NoData,
	// mathexp.Number with a nil loat64, or mathexp.Series that reduce to a nil float64
	isCondNoData = numSeriesNoData == len(data.Values)
	if isCondNoData {
		matches = append(matches, EvalMatch{
			Metric: "NoData",
		})
	}

	return isCondFiring, isCondNoData, matches, nil
}

func (cmd *ConditionsCmd) Type() string {
	return "classic_condition"
}

func compareWithOperator(b1, b2 bool, operator ConditionOperatorType) bool {
	if operator == ConditionOperatorOr || operator == ConditionOperatorLogicOr {
		return b1 || b2
	} else {
		return b1 && b2
	}
}

// EvalMatch represents the series violating the threshold.
// It goes into the metadata of data frames so it can be extracted.
type EvalMatch struct {
	Value  *float64    `json:"value"`
	Metric string      `json:"metric"`
	Labels data.Labels `json:"labels"`
}

func (em EvalMatch) MarshalJSON() ([]byte, error) {
	fs := ""
	if em.Value != nil {
		fs = strconv.FormatFloat(*em.Value, 'f', -1, 64)
	}
	return json.Marshal(struct {
		Value  string      `json:"value"`
		Metric string      `json:"metric"`
		Labels data.Labels `json:"labels"`
	}{
		fs,
		em.Metric,
		em.Labels,
	})
}

// ConditionJSON is the JSON model for a single condition in ConditionsCmd.
// It is based on services/alerting/conditions/query.go's newQueryCondition().
type ConditionJSON struct {
	Evaluator ConditionEvalJSON     `json:"evaluator"`
	Operator  ConditionOperatorJSON `json:"operator"`
	Query     ConditionQueryJSON    `json:"query"`
	Reducer   ConditionReducerJSON  `json:"reducer"`
}

type ConditionEvalJSON struct {
	Params []float64 `json:"params"`
	Type   string    `json:"type"` // e.g. "gt"
}

// The reducer function
// +enum
type ConditionOperatorType string

const (
	ConditionOperatorAnd     ConditionOperatorType = "and"
	ConditionOperatorOr      ConditionOperatorType = "or"
	ConditionOperatorLogicOr ConditionOperatorType = "logic-or"
)

type ConditionOperatorJSON struct {
	Type ConditionOperatorType `json:"type"`
}

type ConditionQueryJSON struct {
	Params []string `json:"params"`
}

type ConditionReducerJSON struct {
	Type string `json:"type"`
	// Params []any `json:"params"` (Unused)
}

func NewConditionCmd(refID string, ccj []ConditionJSON) (*ConditionsCmd, error) {
	c := &ConditionsCmd{
		RefID: refID,
	}

	var err error
	for i, cj := range ccj {
		cond := condition{}

		if i > 0 &&
			cj.Operator.Type != ConditionOperatorAnd &&
			cj.Operator.Type != ConditionOperatorOr &&
			cj.Operator.Type != ConditionOperatorLogicOr {
			return nil, fmt.Errorf("condition %v operator must be `and`, `or` or `logic-or`", i+1)
		}
		cond.Operator = cj.Operator.Type

		if len(cj.Query.Params) == 0 || cj.Query.Params[0] == "" {
			return nil, fmt.Errorf("condition %v is missing the query RefID argument", i+1)
		}

		cond.InputRefID = cj.Query.Params[0]

		cond.Reducer = reducer(cj.Reducer.Type)
		if !cond.Reducer.ValidReduceFunc() {
			return nil, fmt.Errorf("invalid reducer '%v' in condition %v", cond.Reducer, i+1)
		}

		cond.Evaluator, err = newAlertEvaluator(cj.Evaluator)
		if err != nil {
			return nil, err
		}

		c.Conditions = append(c.Conditions, cond)
	}
	return c, nil
}

// UnmarshalConditionsCmd creates a new ConditionsCmd.
func UnmarshalConditionsCmd(rawQuery map[string]any, refID string) (*ConditionsCmd, error) {
	jsonFromM, err := json.Marshal(rawQuery["conditions"])
	if err != nil {
		return nil, fmt.Errorf("failed to remarshal classic condition body: %w", err)
	}
	var ccj []ConditionJSON
	if err = json.Unmarshal(jsonFromM, &ccj); err != nil {
		return nil, fmt.Errorf("failed to unmarshal remarshaled classic condition body: %w", err)
	}
	return NewConditionCmd(refID, ccj)
}
