package rules

import (
	"context"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	pql "github.com/prometheus/prometheus/promql"
	cmock "github.com/srikanthccv/ClickHouse-go-mock"

	"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
	"github.com/SigNoz/signoz/pkg/prometheus"
	"github.com/SigNoz/signoz/pkg/prometheus/prometheustest"
	"github.com/SigNoz/signoz/pkg/telemetrystore"
	"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
	qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
	"github.com/SigNoz/signoz/pkg/types/ruletypes"
	"github.com/SigNoz/signoz/pkg/valuer"
)

func getVectorValues(vectors []ruletypes.Sample) []float64 {
	if len(vectors) == 0 {
		return []float64{} // Return empty slice instead of nil
	}
	var values []float64
	for _, v := range vectors {
		values = append(values, v.V)
	}
	return values
}

func TestPromRuleEval(t *testing.T) {
	postableRule := ruletypes.PostableRule{
		AlertName: "Test Rule",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: valuer.MustParseTextDuration("5m"),
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{
							Query: "dummy_query", // This is not used in the test
						},
					},
				},
			},
		},
	}

	cases := []struct {
		values               pql.Series
		expectAlert          bool
		compareOperator      ruletypes.CompareOperator
		matchType            ruletypes.MatchType
		target               float64
		expectedAlertSample  qbtypes.TimeSeriesValue
		expectedVectorValues []float64 // Expected values in result vector
	}{
		// Test cases for Equals Always
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
				},
			},
			expectAlert:          true,
			compareOperator:      ruletypes.ValueIsEq,
			matchType:            ruletypes.AllTheTimes,
			target:               0.0,
			expectedAlertSample:  qbtypes.TimeSeriesValue{Value: 0.0},
			expectedVectorValues: []float64{0.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 1.0},
				},
			},
			expectAlert:          false,
			compareOperator:      ruletypes.ValueIsEq,
			matchType:            ruletypes.AllTheTimes,
			target:               0.0,
			expectedVectorValues: []float64{},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:          false,
			compareOperator:      ruletypes.ValueIsEq,
			matchType:            ruletypes.AllTheTimes,
			target:               0.0,
			expectedVectorValues: []float64{},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsEq,
			matchType:       ruletypes.AllTheTimes,
			target:          0.0,
		},
		// Test cases for Equals Once
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
				},
			},
			expectAlert:          true,
			compareOperator:      ruletypes.ValueIsEq,
			matchType:            ruletypes.AtleastOnce,
			target:               0.0,
			expectedAlertSample:  qbtypes.TimeSeriesValue{Value: 0.0},
			expectedVectorValues: []float64{0.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 1.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsEq,
			matchType:           ruletypes.AtleastOnce,
			target:              0.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 0.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsEq,
			matchType:           ruletypes.AtleastOnce,
			target:              0.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 0.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:          false,
			compareOperator:      ruletypes.ValueIsEq,
			matchType:            ruletypes.AtleastOnce,
			target:               0.0,
			expectedVectorValues: []float64{},
		},
		// Test cases for Greater Than Always
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:          true,
			compareOperator:      ruletypes.ValueIsAbove,
			matchType:            ruletypes.AllTheTimes,
			target:               1.5,
			expectedAlertSample:  qbtypes.TimeSeriesValue{Value: 2.0},
			expectedVectorValues: []float64{2.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 11.0},
					{F: 4.0},
					{F: 3.0},
					{F: 7.0},
					{F: 12.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsAbove,
			matchType:           ruletypes.AllTheTimes,
			target:              2.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 3.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 11.0},
					{F: 4.0},
					{F: 3.0},
					{F: 7.0},
					{F: 12.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsBelow,
			matchType:           ruletypes.AllTheTimes,
			target:              13.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 12.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AllTheTimes,
			target:          4.5,
		},
		// Test cases for Greater Than Once
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:          true,
			compareOperator:      ruletypes.ValueIsAbove,
			matchType:            ruletypes.AtleastOnce,
			target:               4.5,
			expectedAlertSample:  qbtypes.TimeSeriesValue{Value: 10.0},
			expectedVectorValues: []float64{10.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 4.0},
					{F: 4.0},
					{F: 4.0},
					{F: 4.0},
					{F: 4.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          4.5,
		},
		// Test cases for Not Equals Always
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsNotEq,
			matchType:       ruletypes.AllTheTimes,
			target:          0.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 0.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsNotEq,
			matchType:       ruletypes.AllTheTimes,
			target:          0.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsNotEq,
			matchType:           ruletypes.AllTheTimes,
			target:              0.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 1.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.0},
					{F: 0.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsNotEq,
			matchType:       ruletypes.AllTheTimes,
			target:          0.0,
		},
		// Test cases for Not Equals Once
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsNotEq,
			matchType:           ruletypes.AtleastOnce,
			target:              0.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 1.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
					{F: 0.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsNotEq,
			matchType:       ruletypes.AtleastOnce,
			target:          0.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 0.0},
					{F: 0.0},
					{F: 1.0},
					{F: 0.0},
					{F: 1.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsNotEq,
			matchType:           ruletypes.AtleastOnce,
			target:              0.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 1.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
					{F: 1.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsNotEq,
			matchType:           ruletypes.AtleastOnce,
			target:              0.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 1.0},
		},
		// Test cases for Less Than Always
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 1.5},
					{F: 1.5},
					{F: 1.5},
					{F: 1.5},
					{F: 1.5},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsBelow,
			matchType:           ruletypes.AllTheTimes,
			target:              4,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 1.5},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsBelow,
			matchType:       ruletypes.AllTheTimes,
			target:          4,
		},
		// Test cases for Less Than Once
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
					{F: 2.5},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsBelow,
			matchType:           ruletypes.AtleastOnce,
			target:              4,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 2.5},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
					{F: 4.5},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsBelow,
			matchType:       ruletypes.AtleastOnce,
			target:          4,
		},
		// Test cases for OnAverage
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsEq,
			matchType:           ruletypes.OnAverage,
			target:              6.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 6.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsEq,
			matchType:       ruletypes.OnAverage,
			target:          4.5,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsNotEq,
			matchType:           ruletypes.OnAverage,
			target:              4.5,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 6.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsNotEq,
			matchType:       ruletypes.OnAverage,
			target:          6.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsAbove,
			matchType:           ruletypes.OnAverage,
			target:              4.5,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 6.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsBelow,
			matchType:           ruletypes.OnAverage,
			target:              12.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 6.0},
		},
		// Test cases for InTotal
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsEq,
			matchType:           ruletypes.InTotal,
			target:              30.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 30.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 4.0},
					{F: 6.0},
					{F: 8.0},
					{F: 2.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsEq,
			matchType:       ruletypes.InTotal,
			target:          20.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsNotEq,
			matchType:           ruletypes.InTotal,
			target:              9.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 10.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsNotEq,
			matchType:       ruletypes.InTotal,
			target:          10.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 10.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsAbove,
			matchType:           ruletypes.InTotal,
			target:              10.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 20.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 10.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.InTotal,
			target:          20.0,
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 10.0},
				},
			},
			expectAlert:         true,
			compareOperator:     ruletypes.ValueIsBelow,
			matchType:           ruletypes.InTotal,
			target:              30.0,
			expectedAlertSample: qbtypes.TimeSeriesValue{Value: 20.0},
		},
		{
			values: pql.Series{
				Floats: []pql.FPoint{
					{F: 10.0},
					{F: 10.0},
				},
			},
			expectAlert:     false,
			compareOperator: ruletypes.ValueIsBelow,
			matchType:       ruletypes.InTotal,
			target:          20.0,
		},
	}

	logger := instrumentationtest.New().Logger()

	for idx, c := range cases {
		postableRule.RuleCondition.CompareOperator = c.compareOperator
		postableRule.RuleCondition.MatchType = c.matchType
		postableRule.RuleCondition.Target = &c.target
		postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
			Kind: ruletypes.BasicThresholdKind,
			Spec: ruletypes.BasicRuleThresholds{
				{
					TargetValue:     &c.target,
					MatchType:       c.matchType,
					CompareOperator: c.compareOperator,
				},
			},
		}

		rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, nil)
		if err != nil {
			assert.NoError(t, err)
		}

		resultVectors, err := rule.Threshold.Eval(toCommonSeries(c.values), rule.Unit(), ruletypes.EvalData{})
		assert.NoError(t, err)

		// Compare full result vector with expected vector
		actualValues := getVectorValues(resultVectors)
		if c.expectedVectorValues != nil {
			// If expected vector values are specified, compare them exactly
			assert.Equal(t, c.expectedVectorValues, actualValues, "Result vector values don't match expected for case %d", idx)
		} else {
			// Fallback to the old logic for cases without expectedVectorValues
			if c.expectAlert {
				assert.NotEmpty(t, resultVectors, "Expected alert but got no result vectors for case %d", idx)
				// Verify at least one of the result vectors matches the expected alert sample
				if len(resultVectors) > 0 {
					found := false
					for _, sample := range resultVectors {
						if sample.V == c.expectedAlertSample.Value {
							found = true
							break
						}
					}
					assert.True(t, found, "Expected alert sample value %.2f not found in result vectors for case %d. Got values: %v", c.expectedAlertSample.Value, idx, actualValues)
				}
			} else {
				assert.Empty(t, resultVectors, "Expected no alert but got result vectors for case %d", idx)
			}
		}
	}
}

func TestPromRuleUnitCombinations(t *testing.T) {
	// fixed base time for deterministic tests
	baseTime := time.Unix(1700000000, 0)
	evalTime := baseTime.Add(5 * time.Minute)

	postableRule := ruletypes.PostableRule{
		AlertName: "Units test",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: valuer.MustParseTextDuration("5m"),
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{
							Query: "test_metric",
						},
					},
				},
			},
		},
	}

	// time_series_v4 cols of interest
	fingerprintCols := []cmock.ColumnType{
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "any(labels)", Type: "String"},
	}

	// samples_v4 columns
	samplesCols := []cmock.ColumnType{
		{Name: "metric_name", Type: "String"},
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "unix_milli", Type: "Int64"},
		{Name: "value", Type: "Float64"},
		{Name: "flags", Type: "UInt32"},
	}

	// see Timestamps on base_rule
	evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
	evalTimeMs := evalTime.UnixMilli()
	queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
	queryEnd := (evalTimeMs / 60000) * 60000                    // truncate to minute

	cases := []struct {
		targetUnit string
		yAxisUnit  string
		values     []struct {
			timestamp time.Time
			value     float64
		}
		expectAlerts    int
		compareOperator ruletypes.CompareOperator
		matchType       ruletypes.MatchType
		target          float64
		summaryAny      []string
	}{
		{
			targetUnit: "s",
			yAxisUnit:  "ns",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 572588400},                              // 0.57 seconds
				{baseTime.Add(1 * time.Minute), 572386400},         // 0.57 seconds
				{baseTime.Add(2 * time.Minute), 300947400},         // 0.3 seconds
				{baseTime.Add(3 * time.Minute), 299316000},         // 0.3 seconds
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 0.06 seconds
			},
			expectAlerts:    0,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          1, // 1 second
		},
		{
			targetUnit: "ms",
			yAxisUnit:  "ns",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 572588400},                              // 572.58 ms
				{baseTime.Add(1 * time.Minute), 572386400},         // 572.38 ms
				{baseTime.Add(2 * time.Minute), 300947400},         // 300.94 ms
				{baseTime.Add(3 * time.Minute), 299316000},         // 299.31 ms
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 66.64 ms
			},
			expectAlerts:    1,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          200, // 200 ms
			summaryAny: []string{
				"observed metric value is 299 ms",
				"the observed metric value is 573 ms",
				"the observed metric value is 572 ms",
				"the observed metric value is 301 ms",
			},
		},
		{
			targetUnit: "decgbytes",
			yAxisUnit:  "bytes",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 2863284053},                             // 2.86 GB
				{baseTime.Add(1 * time.Minute), 2863388842},        // 2.86 GB
				{baseTime.Add(2 * time.Minute), 300947400},         // 0.3 GB
				{baseTime.Add(3 * time.Minute), 299316000},         // 0.3 GB
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 66.64 MB
			},
			expectAlerts:    0,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          200, // 200 GB
		},
		{
			targetUnit: "decgbytes",
			yAxisUnit:  "By",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 2863284053},                             // 2.86 GB
				{baseTime.Add(1 * time.Minute), 2863388842},        // 2.86 GB
				{baseTime.Add(2 * time.Minute), 300947400},         // 0.3 GB
				{baseTime.Add(3 * time.Minute), 299316000},         // 0.3 GB
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 66.64 MB
			},
			expectAlerts:    0,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          200, // 200 GB
		},
		{
			targetUnit: "h",
			yAxisUnit:  "min",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 55},                      // 55 minutes
				{baseTime.Add(1 * time.Minute), 57}, // 57 minutes
				{baseTime.Add(2 * time.Minute), 30}, // 30 minutes
				{baseTime.Add(3 * time.Minute), 29}, // 29 minutes
			},
			expectAlerts:    0,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          1, // 1 hour
		},
	}

	logger := instrumentationtest.New().Logger()

	for idx, c := range cases {
		telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})

		// single fingerprint with labels JSON
		fingerprint := uint64(12345)
		labelsJSON := `{"__name__":"test_metric"}`
		fingerprintData := [][]any{
			{fingerprint, labelsJSON},
		}
		fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)

		// create samples data from test case values
		samplesData := make([][]any, len(c.values))
		for i, v := range c.values {
			samplesData[i] = []any{
				"test_metric",
				fingerprint,
				v.timestamp.UnixMilli(),
				v.value,
				uint32(0), // flags - 0 means normal value, 1 means stale, we are not doing staleness tests
			}
		}
		samplesRows := cmock.NewRows(samplesCols, samplesData)

		// args: $1=metric_name, $2=label_name, $3=label_value
		telemetryStore.Mock().
			ExpectQuery("SELECT fingerprint, any").
			WithArgs("test_metric", "__name__", "test_metric").
			WillReturnRows(fingerprintRows)

		// args: $1=metric_name (outer), $2=metric_name (subquery), $3=label_name, $4=label_value, $5=start, $6=end
		telemetryStore.Mock().
			ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
			WithArgs(
				"test_metric",
				"test_metric",
				"__name__",
				"test_metric",
				queryStart,
				queryEnd,
			).
			WillReturnRows(samplesRows)

		promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)

		postableRule.RuleCondition.CompareOperator = c.compareOperator
		postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
		postableRule.RuleCondition.Target = &c.target
		postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
		postableRule.RuleCondition.TargetUnit = c.targetUnit
		postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
			Kind: ruletypes.BasicThresholdKind,
			Spec: ruletypes.BasicRuleThresholds{
				{
					Name:            postableRule.AlertName,
					TargetValue:     &c.target,
					TargetUnit:      c.targetUnit,
					MatchType:       c.matchType,
					CompareOperator: c.compareOperator,
				},
			},
		}
		postableRule.Annotations = map[string]string{
			"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
			"summary":     "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
		}

		rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider)
		if err != nil {
			assert.NoError(t, err)
			promProvider.Close()
			continue
		}

		alertsFound, err := rule.Eval(context.Background(), evalTime)
		if err != nil {
			assert.NoError(t, err)
			promProvider.Close()
			continue
		}

		assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
		if c.expectAlerts != 0 {
			foundCount := 0
			for _, item := range rule.Active {
				for _, summary := range c.summaryAny {
					if strings.Contains(item.Annotations.Get("summary"), summary) {
						foundCount++
						break
					}
				}
			}
			assert.Equal(t, c.expectAlerts, foundCount, "case %d", idx)
		}

		promProvider.Close()
	}
}

func TestPromRuleNoData(t *testing.T) {
	baseTime := time.Unix(1700000000, 0)
	evalTime := baseTime.Add(5 * time.Minute)

	postableRule := ruletypes.PostableRule{
		AlertName: "No data test",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: valuer.MustParseTextDuration("5m"),
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{
							Query: "test_metric",
						},
					},
				},
			},
			AlertOnAbsent: true,
		},
	}

	// time_series_v4 cols of interest
	fingerprintCols := []cmock.ColumnType{
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "any(labels)", Type: "String"},
	}

	cases := []struct {
		values []struct {
			timestamp time.Time
			value     float64
		}
		expectNoData bool
	}{
		{
			values: []struct {
				timestamp time.Time
				value     float64
			}{},
			expectNoData: true,
		},
	}

	logger := instrumentationtest.New().Logger()

	for idx, c := range cases {
		telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})

		// no data
		fingerprintData := [][]any{}
		fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)

		// no rows == no data
		telemetryStore.Mock().
			ExpectQuery("SELECT fingerprint, any").
			WithArgs("test_metric", "__name__", "test_metric").
			WillReturnRows(fingerprintRows)

		promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)

		var target float64 = 0
		postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
			Kind: ruletypes.BasicThresholdKind,
			Spec: ruletypes.BasicRuleThresholds{
				{
					Name:            postableRule.AlertName,
					TargetValue:     &target,
					MatchType:       ruletypes.AtleastOnce,
					CompareOperator: ruletypes.ValueIsEq,
				},
			},
		}
		postableRule.Annotations = map[string]string{
			"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
			"summary":     "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
		}

		rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider)
		if err != nil {
			assert.NoError(t, err)
			promProvider.Close()
			continue
		}

		alertsFound, err := rule.Eval(context.Background(), evalTime)
		if err != nil {
			assert.NoError(t, err)
			promProvider.Close()
			continue
		}

		assert.Equal(t, 1, alertsFound, "case %d", idx)
		for _, item := range rule.Active {
			if c.expectNoData {
				assert.True(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
			} else {
				assert.False(t, strings.Contains(item.Labels.Get(ruletypes.AlertNameLabel), "[No data]"), "case %d", idx)
			}
		}

		promProvider.Close()
	}
}

func TestMultipleThresholdPromRule(t *testing.T) {
	// fixed base time for deterministic tests
	baseTime := time.Unix(1700000000, 0)
	evalTime := baseTime.Add(5 * time.Minute)

	postableRule := ruletypes.PostableRule{
		AlertName: "Multiple threshold test",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: valuer.MustParseTextDuration("5m"),
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{
							Query: "test_metric",
						},
					},
				},
			},
		},
	}

	fingerprintCols := []cmock.ColumnType{
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "any(labels)", Type: "String"},
	}

	samplesCols := []cmock.ColumnType{
		{Name: "metric_name", Type: "String"},
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "unix_milli", Type: "Int64"},
		{Name: "value", Type: "Float64"},
		{Name: "flags", Type: "UInt32"},
	}

	// see .Timestamps of base rule
	evalWindowMs := int64(5 * 60 * 1000)
	evalTimeMs := evalTime.UnixMilli()
	queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1
	queryEnd := (evalTimeMs / 60000) * 60000

	cases := []struct {
		targetUnit string
		yAxisUnit  string
		values     []struct {
			timestamp time.Time
			value     float64
		}
		expectAlerts    int
		compareOperator ruletypes.CompareOperator
		matchType       ruletypes.MatchType
		target          float64
		secondTarget    float64
		summaryAny      []string
	}{
		{
			targetUnit: "s",
			yAxisUnit:  "ns",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 572588400},                              // 0.57 seconds
				{baseTime.Add(1 * time.Minute), 572386400},         // 0.57 seconds
				{baseTime.Add(2 * time.Minute), 300947400},         // 0.3 seconds
				{baseTime.Add(3 * time.Minute), 299316000},         // 0.3 seconds
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 0.06 seconds
			},
			expectAlerts:    1,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          1, // 1 second
			secondTarget:    .5,
			summaryAny: []string{
				"observed metric value is 573 ms",
				"observed metric value is 572 ms",
			},
		},
		{
			targetUnit: "ms",
			yAxisUnit:  "ns",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 572588400},                              // 572.58 ms
				{baseTime.Add(1 * time.Minute), 572386400},         // 572.38 ms
				{baseTime.Add(2 * time.Minute), 300947400},         // 300.94 ms
				{baseTime.Add(3 * time.Minute), 299316000},         // 299.31 ms
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 66.64 ms
			},
			expectAlerts:    2, // One alert per threshold that fires
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          200, // 200 ms
			secondTarget:    500,
			summaryAny: []string{
				"observed metric value is 299 ms",
				"the observed metric value is 573 ms",
				"the observed metric value is 572 ms",
				"the observed metric value is 301 ms",
			},
		},
		{
			targetUnit: "decgbytes",
			yAxisUnit:  "bytes",
			values: []struct {
				timestamp time.Time
				value     float64
			}{
				{baseTime, 2863284053},                             // 2.86 GB
				{baseTime.Add(1 * time.Minute), 2863388842},        // 2.86 GB
				{baseTime.Add(2 * time.Minute), 300947400},         // 0.3 GB
				{baseTime.Add(3 * time.Minute), 299316000},         // 0.3 GB
				{baseTime.Add(4 * time.Minute), 66640400.00000001}, // 66.64 MB
			},
			expectAlerts:    1,
			compareOperator: ruletypes.ValueIsAbove,
			matchType:       ruletypes.AtleastOnce,
			target:          200, // 200 GB
			secondTarget:    2,   // 2GB
			summaryAny: []string{
				"observed metric value is 2.7 GiB",
				"the observed metric value is 0.3 GB",
			},
		},
	}

	logger := instrumentationtest.New().Logger()

	for idx, c := range cases {
		telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})

		fingerprint := uint64(12345)
		labelsJSON := `{"__name__":"test_metric"}`
		fingerprintData := [][]any{
			{fingerprint, labelsJSON},
		}
		fingerprintRows := cmock.NewRows(fingerprintCols, fingerprintData)

		samplesData := make([][]any, len(c.values))
		for i, v := range c.values {
			samplesData[i] = []any{
				"test_metric",
				fingerprint,
				v.timestamp.UnixMilli(),
				v.value,
				uint32(0),
			}
		}
		samplesRows := cmock.NewRows(samplesCols, samplesData)

		telemetryStore.Mock().
			ExpectQuery("SELECT fingerprint, any").
			WithArgs("test_metric", "__name__", "test_metric").
			WillReturnRows(fingerprintRows)

		telemetryStore.Mock().
			ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
			WithArgs(
				"test_metric",
				"test_metric",
				"__name__",
				"test_metric",
				queryStart,
				queryEnd,
			).
			WillReturnRows(samplesRows)

		promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)

		postableRule.RuleCondition.CompareOperator = c.compareOperator
		postableRule.RuleCondition.MatchType = c.matchType
		postableRule.RuleCondition.Target = &c.target
		postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
		postableRule.RuleCondition.TargetUnit = c.targetUnit
		postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
			Kind: ruletypes.BasicThresholdKind,
			Spec: ruletypes.BasicRuleThresholds{
				{
					Name:            "first_threshold",
					TargetValue:     &c.target,
					TargetUnit:      c.targetUnit,
					MatchType:       c.matchType,
					CompareOperator: c.compareOperator,
				},
				{
					Name:            "second_threshold",
					TargetValue:     &c.secondTarget,
					TargetUnit:      c.targetUnit,
					MatchType:       c.matchType,
					CompareOperator: c.compareOperator,
				},
			},
		}
		postableRule.Annotations = map[string]string{
			"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
			"summary":     "The rule threshold is set to {{$threshold}}, and the observed metric value is {{$value}}",
		}

		rule, err := NewPromRule("69", valuer.GenerateUUID(), &postableRule, logger, promProvider)
		if err != nil {
			assert.NoError(t, err)
			promProvider.Close()
			continue
		}

		alertsFound, err := rule.Eval(context.Background(), evalTime)
		if err != nil {
			assert.NoError(t, err)
			promProvider.Close()
			continue
		}

		assert.Equal(t, c.expectAlerts, alertsFound, "case %d", idx)
		if c.expectAlerts != 0 {
			foundCount := 0
			for _, item := range rule.Active {
				for _, summary := range c.summaryAny {
					if strings.Contains(item.Annotations.Get("summary"), summary) {
						foundCount++
						break
					}
				}
			}
			assert.Equal(t, c.expectAlerts, foundCount, "case %d", idx)
		}

		promProvider.Close()
	}
}

func TestPromRule_NoData(t *testing.T) {
	evalTime := time.Now()

	postableRule := ruletypes.PostableRule{
		AlertName: "Test no data",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: valuer.MustParseTextDuration("5m"),
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompareOperator: ruletypes.ValueIsAbove,
			MatchType:       ruletypes.AtleastOnce,
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{Query: "test_metric"},
					},
				},
			},
			Thresholds: &ruletypes.RuleThresholdData{
				Kind: ruletypes.BasicThresholdKind,
				Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
			},
		},
	}

	// time_series_v4 cols of interest
	fingerprintCols := []cmock.ColumnType{
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "any(labels)", Type: "String"},
	}

	// samples_v4 columns
	samplesCols := []cmock.ColumnType{
		{Name: "metric_name", Type: "String"},
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "unix_milli", Type: "Int64"},
		{Name: "value", Type: "Float64"},
		{Name: "flags", Type: "UInt32"},
	}

	// see Timestamps on base_rule
	evalWindowMs := int64(5 * 60 * 1000) // 5 minutes in ms
	evalTimeMs := evalTime.UnixMilli()
	queryStart := ((evalTimeMs-2*evalWindowMs)/60000)*60000 + 1 // truncate to minute + 1ms
	queryEnd := (evalTimeMs / 60000) * 60000                    // truncate to minute

	cases := []struct {
		description   string
		alertOnAbsent bool
		values        []any
		target        float64
		expectAlerts  int
	}{
		{
			description:   "AlertOnAbsent=false",
			alertOnAbsent: false,
			values:        []any{},
			target:        200,
			expectAlerts:  0,
		},
		{
			description:   "AlertOnAbsent=true",
			alertOnAbsent: true,
			values:        []any{},
			target:        200,
			expectAlerts:  1,
		},
	}

	logger := instrumentationtest.New().Logger()

	for _, c := range cases {
		t.Run(c.description, func(t *testing.T) {
			postableRule.RuleCondition.AlertOnAbsent = c.alertOnAbsent

			telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})

			// single fingerprint with labels JSON
			fingerprint := uint64(12345)
			labelsJSON := `{"__name__":"test_metric"}`
			telemetryStore.Mock().
				ExpectQuery("SELECT fingerprint, any").
				WithArgs("test_metric", "__name__", "test_metric").
				WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))

			telemetryStore.Mock().
				ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
				WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
				WillReturnRows(cmock.NewRows(samplesCols, [][]any{}))

			promProvider := prometheustest.New(
				context.Background(),
				instrumentationtest.New().ToProviderSettings(),
				prometheus.Config{Timeout: 2 * time.Minute},
				telemetryStore,
			)
			defer func() {
				_ = promProvider.Close()
			}()

			rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider)
			require.NoError(t, err)

			alertsFound, err := rule.Eval(context.Background(), evalTime)
			require.NoError(t, err)

			assert.Equal(t, c.expectAlerts, alertsFound)
		})
	}
}

func TestPromRule_NoData_AbsentFor(t *testing.T) {
	// 1. Call Eval with data at time t1, to populate lastTimestampWithDatapoints
	// 2. Call Eval without data at time t2
	// 3. Alert fires only if t2 - t1 > AbsentFor

	baseTime := time.Unix(1700000000, 0)
	evalWindow := valuer.MustParseTextDuration("5m")

	// Set target higher than test data (100.0) so regular threshold alerts don't fire
	target := 500.0

	postableRule := ruletypes.PostableRule{
		AlertName: "Test no data with AbsentFor",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: evalWindow,
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompareOperator: ruletypes.ValueIsAbove,
			MatchType:       ruletypes.AtleastOnce,
			AlertOnAbsent:   true,
			Target:          &target,
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{Query: "test_metric"},
					},
				},
			},
			Thresholds: &ruletypes.RuleThresholdData{
				Kind: ruletypes.BasicThresholdKind,
				Spec: ruletypes.BasicRuleThresholds{{
					Name:            "Test no data with AbsentFor",
					TargetValue:     &target,
					MatchType:       ruletypes.AtleastOnce,
					CompareOperator: ruletypes.ValueIsAbove,
				}},
			},
		},
	}

	fingerprintCols := []cmock.ColumnType{
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "any(labels)", Type: "String"},
	}

	samplesCols := []cmock.ColumnType{
		{Name: "metric_name", Type: "String"},
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "unix_milli", Type: "Int64"},
		{Name: "value", Type: "Float64"},
		{Name: "flags", Type: "UInt32"},
	}

	cases := []struct {
		description        string
		absentFor          uint64        // grace period in minutes
		timeBetweenEvals   time.Duration // time between first eval (with data) and second eval (no data)
		expectAlertOnEval2 int
	}{
		{
			description:        "WithinGracePeriod",
			absentFor:          5,
			timeBetweenEvals:   4 * time.Minute,
			expectAlertOnEval2: 0,
		},
		{
			description:        "AfterGracePeriod",
			absentFor:          5,
			timeBetweenEvals:   6 * time.Minute,
			expectAlertOnEval2: 1,
		},
	}

	logger := instrumentationtest.New().Logger()

	for _, c := range cases {
		t.Run(c.description, func(t *testing.T) {
			postableRule.RuleCondition.AbsentFor = c.absentFor

			// Timestamps for two evaluations
			// t1 is the eval time for first eval, data points are in the past
			t1 := baseTime.Add(5 * time.Minute) // first eval with data
			t2 := t1.Add(c.timeBetweenEvals)    // second eval without data

			telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})

			fingerprint := uint64(12345)
			labelsJSON := `{"__name__":"test_metric"}`

			// Helper to calculate query time range for an eval time
			calcQueryRange := func(evalTime time.Time) (int64, int64) {
				evalTimeMs := evalTime.UnixMilli()
				queryStart := ((evalTimeMs-2*evalWindow.Milliseconds())/60000)*60000 + 1
				queryEnd := (evalTimeMs / 60000) * 60000
				return queryStart, queryEnd
			}

			// First eval (t1) - with data
			queryStart1, queryEnd1 := calcQueryRange(t1)
			telemetryStore.Mock().
				ExpectQuery("SELECT fingerprint, any").
				WithArgs("test_metric", "__name__", "test_metric").
				WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
			telemetryStore.Mock().
				ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
				WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart1, queryEnd1).
				WillReturnRows(cmock.NewRows(samplesCols, [][]any{
					// Data points in the past relative to t1
					{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, uint32(0)},
					{"test_metric", fingerprint, baseTime.Add(1 * time.Minute).UnixMilli(), 100.0, uint32(0)},
					{"test_metric", fingerprint, baseTime.Add(2 * time.Minute).UnixMilli(), 100.0, uint32(0)},
				}))

			// Second eval (t2) - no data
			queryStart2, queryEnd2 := calcQueryRange(t2)
			telemetryStore.Mock().
				ExpectQuery("SELECT fingerprint, any").
				WithArgs("test_metric", "__name__", "test_metric").
				WillReturnRows(cmock.NewRows(fingerprintCols, [][]any{{fingerprint, labelsJSON}}))
			telemetryStore.Mock().
				ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
				WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart2, queryEnd2).
				WillReturnRows(cmock.NewRows(samplesCols, [][]any{})) // empty - no data

			promProvider := prometheustest.New(
				context.Background(),
				instrumentationtest.New().ToProviderSettings(),
				prometheus.Config{Timeout: 2 * time.Minute},
				telemetryStore,
			)
			defer func() {
				_ = promProvider.Close()
			}()

			rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider)
			require.NoError(t, err)

			// First eval with data - should NOT alert, but populates lastTimestampWithDatapoints
			alertsFound1, err := rule.Eval(context.Background(), t1)
			require.NoError(t, err)
			assert.Equal(t, 0, alertsFound1, "First eval with data should not alert")

			// Second eval without data - should alert based on AbsentFor
			alertsFound2, err := rule.Eval(context.Background(), t2)
			require.NoError(t, err)
			assert.Equal(t, c.expectAlertOnEval2, alertsFound2)
		})
	}
}

func TestPromRuleEval_RequireMinPoints(t *testing.T) {
	// fixed base time for deterministic tests
	baseTime := time.Unix(1700000000, 0)
	evalTime := baseTime.Add(5 * time.Minute)

	evalWindow := valuer.MustParseTextDuration("5m")
	lookBackDelta := time.Minute

	postableRule := ruletypes.PostableRule{
		AlertName: "Unit test",
		AlertType: ruletypes.AlertTypeMetric,
		RuleType:  ruletypes.RuleTypeProm,
		Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
			EvalWindow: evalWindow,
			Frequency:  valuer.MustParseTextDuration("1m"),
		}},
		RuleCondition: &ruletypes.RuleCondition{
			CompareOperator: ruletypes.ValueIsAbove,
			MatchType:       ruletypes.AtleastOnce,
			CompositeQuery: &ruletypes.AlertCompositeQuery{
				QueryType: ruletypes.QueryTypePromQL,
				Queries: []qbtypes.QueryEnvelope{
					{
						Type: qbtypes.QueryTypePromQL,
						Spec: qbtypes.PromQuery{Query: "test_metric"},
					},
				},
			},
		},
	}

	fingerprintCols := []cmock.ColumnType{
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "any(labels)", Type: "String"},
	}
	fingerprint := uint64(12345)
	fingerprintData := [][]any{{fingerprint, `{"__name__":"test_metric"}`}}

	samplesCols := []cmock.ColumnType{
		{Name: "metric_name", Type: "String"},
		{Name: "fingerprint", Type: "UInt64"},
		{Name: "unix_milli", Type: "Int64"},
		{Name: "value", Type: "Float64"},
		{Name: "flags", Type: "UInt32"},
	}
	samplesData := [][]any{
		{"test_metric", fingerprint, baseTime.UnixMilli(), 100.0, 0},
		{"test_metric", fingerprint, baseTime.Add(time.Minute).UnixMilli(), 150.0, 0},
		{"test_metric", fingerprint, baseTime.Add(2 * time.Minute).UnixMilli(), 250.0, 0},
	}
	targetForAlert := 200.0
	targetForNoAlert := 500.0

	// see Timestamps on base_rule
	evalTimeMs := evalTime.UnixMilli()
	queryStart := ((evalTimeMs-evalWindow.Milliseconds()-lookBackDelta.Milliseconds())/60000)*60000 + 1 // truncate to minute + 1ms
	queryEnd := (evalTimeMs / 60000) * 60000                                                            // truncate to minute

	cases := []struct {
		description       string
		alertCondition    bool
		requireMinPoints  bool
		requiredNumPoints int
		expectAlerts      int
	}{
		{
			description:      "AlertCondition=false, RequireMinPoints=false",
			alertCondition:   false,
			requireMinPoints: false,
			expectAlerts:     0,
		},
		{
			description:      "AlertCondition=true, RequireMinPoints=false",
			alertCondition:   true,
			requireMinPoints: false,
			expectAlerts:     1,
		},
		{
			description:       "AlertCondition=true, RequireMinPoints=true, NumPoints=more_than_required",
			alertCondition:    true,
			requireMinPoints:  true,
			requiredNumPoints: 2,
			expectAlerts:      1,
		},
		{
			description:       "AlertCondition=true, RequireMinPoints=true, NumPoints=same_as_required",
			alertCondition:    true,
			requireMinPoints:  true,
			requiredNumPoints: 3,
			expectAlerts:      1,
		},
		{
			description:       "AlertCondition=true, RequireMinPoints=true, NumPoints=insufficient",
			alertCondition:    true,
			requireMinPoints:  true,
			requiredNumPoints: 4,
			expectAlerts:      0,
		},
	}

	logger := instrumentationtest.New().Logger()

	for _, c := range cases {
		rc := postableRule.RuleCondition

		rc.Target = &targetForNoAlert
		if c.alertCondition {
			rc.Target = &targetForAlert
		}
		rc.RequireMinPoints = c.requireMinPoints
		rc.RequiredNumPoints = c.requiredNumPoints
		rc.Thresholds = &ruletypes.RuleThresholdData{
			Kind: ruletypes.BasicThresholdKind,
			Spec: ruletypes.BasicRuleThresholds{
				{
					Name:            postableRule.AlertName,
					TargetValue:     rc.Target,
					MatchType:       rc.MatchType,
					CompareOperator: rc.CompareOperator,
				},
			},
		}

		t.Run(c.description, func(t *testing.T) {
			telemetryStore := telemetrystoretest.New(telemetrystore.Config{}, &queryMatcherAny{})
			telemetryStore.Mock().
				ExpectQuery("SELECT fingerprint, any").
				WithArgs("test_metric", "__name__", "test_metric").
				WillReturnRows(cmock.NewRows(fingerprintCols, fingerprintData))
			telemetryStore.Mock().
				ExpectQuery("SELECT metric_name, fingerprint, unix_milli").
				WithArgs("test_metric", "test_metric", "__name__", "test_metric", queryStart, queryEnd).
				WillReturnRows(cmock.NewRows(samplesCols, samplesData))
			promProvider := prometheustest.New(
				context.Background(),
				instrumentationtest.New().ToProviderSettings(),
				prometheus.Config{Timeout: 2 * time.Minute, LookbackDelta: lookBackDelta},
				telemetryStore,
			)
			defer func() {
				_ = promProvider.Close()
			}()

			rule, err := NewPromRule("some-id", valuer.GenerateUUID(), &postableRule, logger, promProvider)
			require.NoError(t, err)

			alertsFound, err := rule.Eval(context.Background(), evalTime)
			require.NoError(t, err)

			assert.Equal(t, c.expectAlerts, alertsFound)
		})
	}
}
