// Package rules evaluates Prometheus recording rules against the embedded
// metrics TSDB independently from scrape scheduling.
package rules

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"time"

	commonmodel "github.com/prometheus/common/model"
	"github.com/prometheus/common/promslog"
	promlabels "github.com/prometheus/prometheus/model/labels"
	"github.com/prometheus/prometheus/promql"
	"github.com/prometheus/prometheus/promql/parser"
	promrules "github.com/prometheus/prometheus/rules"
	"github.com/prometheus/prometheus/storage"
	"sigs.k8s.io/controller-runtime/pkg/log"
)

// Record defines one Prometheus-compatible recording rule. The expression can
// query any series present in Queryable; its result is stored under Record.
type Record struct {
	Record string            `json:"record"`
	Expr   string            `json:"expr"`
	Labels map[string]string `json:"labels,omitempty"`
}

// Config configures an independently scheduled recording-rule evaluator.
type Config struct {
	Interval      time.Duration
	QueryTimeout  time.Duration
	MaxSamples    int
	LookbackDelta time.Duration
	Records       []Record
	Queryable     storage.Queryable
	Appendable    storage.Appendable
}

// Evaluator periodically evaluates one implicit recording-rule group. It is
// intentionally independent from every scraper and observes the TSDB snapshot
// available at each evaluation timestamp.
type Evaluator struct {
	interval time.Duration
	group    *promrules.Group
	rules    []*promrules.RecordingRule
}

// New constructs a recording-rule evaluator.
func New(cfg *Config) (*Evaluator, error) {
	if cfg == nil {
		return nil, fmt.Errorf("rule evaluator: config is required")
	}
	if cfg.Interval <= 0 {
		return nil, fmt.Errorf("rule evaluator: interval must be greater than zero")
	}
	if cfg.QueryTimeout <= 0 {
		return nil, fmt.Errorf("rule evaluator: query timeout must be greater than zero")
	}
	if cfg.MaxSamples <= 0 {
		return nil, fmt.Errorf("rule evaluator: max samples must be greater than zero")
	}
	if cfg.LookbackDelta <= 0 {
		return nil, fmt.Errorf("rule evaluator: lookback delta must be greater than zero")
	}
	if len(cfg.Records) == 0 {
		return nil, fmt.Errorf("rule evaluator: records are required")
	}
	if cfg.Queryable == nil {
		return nil, fmt.Errorf("rule evaluator: queryable storage is required")
	}
	if cfg.Appendable == nil {
		return nil, fmt.Errorf("rule evaluator: appendable storage is required")
	}

	queryEngine := promql.NewEngine(promql.EngineOpts{
		Logger:               promslog.NewNopLogger(),
		MaxSamples:           cfg.MaxSamples,
		Timeout:              cfg.QueryTimeout,
		LookbackDelta:        cfg.LookbackDelta,
		EnableAtModifier:     true,
		EnableNegativeOffset: true,
		NoStepSubqueryIntervalFn: func(int64) int64 {
			return cfg.Interval.Milliseconds()
		},
	})
	compiled := make([]*promrules.RecordingRule, len(cfg.Records))
	groupRules := make([]promrules.Rule, len(cfg.Records))
	promQLParser := parser.NewParser(parser.Options{})
	for i := range cfg.Records {
		configured := &cfg.Records[i]
		if !commonmodel.LegacyValidation.IsValidMetricName(configured.Record) {
			return nil, fmt.Errorf("rule evaluator: records[%d].record: invalid metric name %q", i, configured.Record)
		}
		if strings.TrimSpace(configured.Expr) == "" {
			return nil, fmt.Errorf("rule evaluator: records[%d].expr: expression is required", i)
		}
		expr, err := promQLParser.ParseExpr(configured.Expr)
		if err != nil {
			return nil, fmt.Errorf("rule evaluator: records[%d].expr: invalid PromQL: %w", i, err)
		}
		for name := range configured.Labels {
			if !commonmodel.LegacyValidation.IsValidLabelName(name) {
				return nil, fmt.Errorf("rule evaluator: records[%d].labels: invalid label name %q", i, name)
			}
			if name == promlabels.MetricName {
				return nil, fmt.Errorf("rule evaluator: records[%d].labels: metric name cannot be overridden", i)
			}
		}
		compiled[i] = promrules.NewRecordingRule(
			configured.Record,
			expr,
			promlabels.FromMap(configured.Labels),
		)
		groupRules[i] = compiled[i]
	}
	managerOptions := &promrules.ManagerOptions{
		ExternalURL: &url.URL{},
		QueryFunc:   promrules.EngineQueryFunc(queryEngine, cfg.Queryable),
		Appendable:  cfg.Appendable,
		Queryable:   cfg.Queryable,
		Logger:      promslog.NewNopLogger(),
		Metrics:     promrules.NewGroupMetrics(nil),
	}
	group := promrules.NewGroup(promrules.GroupOptions{
		Name:     "capacity-planner",
		File:     "capacity-planner.yaml",
		Interval: cfg.Interval,
		Rules:    groupRules,
		Opts:     managerOptions,
	})
	return &Evaluator{interval: cfg.Interval, group: group, rules: compiled}, nil
}

// Start evaluates rules on their own interval. Evaluation is not synchronized
// with any scrape cycle, matching Prometheus's independent scheduling model.
func (e *Evaluator) Start(ctx context.Context) error {
	ticker := time.NewTicker(e.interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return nil
		case evaluationTime := <-ticker.C:
			if err := e.Evaluate(ctx, evaluationTime); err != nil && ctx.Err() == nil {
				log.FromContext(ctx).Error(err, "unable to evaluate recording rules")
			}
		}
	}
}

// Evaluate runs all configured recording rules at evaluationTime.
func (e *Evaluator) Evaluate(ctx context.Context, evaluationTime time.Time) error {
	e.group.Eval(ctx, evaluationTime)
	var evaluationErrors []error
	for _, rule := range e.rules {
		if err := rule.LastError(); err != nil {
			evaluationErrors = append(evaluationErrors, fmt.Errorf("recording rule %q: %w", rule.Name(), err))
		}
	}
	return errors.Join(evaluationErrors...)
}

// NeedLeaderElection ensures only the active manager evaluates and writes
// recording rules.
func (*Evaluator) NeedLeaderElection() bool {
	return true
}
