package checks

import (
	"cmp"
	"context"
	"fmt"
	"slices"
	"strconv"
	"strings"

	"github.com/cloudflare/pint/internal/diags"
	"github.com/cloudflare/pint/internal/discovery"
	"github.com/cloudflare/pint/internal/parser/source"
	"github.com/cloudflare/pint/internal/parser/utils"

	"github.com/prometheus/prometheus/model/labels"
	promParser "github.com/prometheus/prometheus/promql/parser"
)

const (
	RuleDependencyCheckName = "rule/dependency"
)

func NewRuleDependencyCheck() RuleDependencyCheck {
	return RuleDependencyCheck{}
}

type RuleDependencyCheck struct{}

func (c RuleDependencyCheck) Meta() CheckMeta {
	return CheckMeta{
		States: []discovery.ChangeType{
			discovery.Noop,
			discovery.Added,
			discovery.Modified,
			discovery.Removed,
		},
		Online:        false,
		AlwaysEnabled: false,
	}
}

func (c RuleDependencyCheck) String() string {
	return RuleDependencyCheckName
}

func (c RuleDependencyCheck) Reporter() string {
	return RuleDependencyCheckName
}

func (c RuleDependencyCheck) Check(_ context.Context, entry *discovery.Entry, entries []*discovery.Entry) (problems []Problem) {
	problems = append(problems, c.checkRemovedDependency(entry, entries)...)
	problems = append(problems, c.checkCrossGroupDependency(entry, entries)...)
	return problems
}

func (c RuleDependencyCheck) checkRemovedDependency(entry *discovery.Entry, entries []*discovery.Entry) (problems []Problem) {
	if entry.Path.Name != entry.Path.SymlinkTarget {
		// Don't reported symlinks that are being removed.
		return problems
	}

	filtered := nonRemovedEntries(entries)

	for _, flt := range filtered {
		if flt.Rule.Type() == entry.Rule.Type() && flt.Rule.Name() == entry.Rule.Name() {
			// There's another rule with same type & name, do nothing.
			return problems
		}
	}

	var broken []*brokenDependency
	var dep *brokenDependency
	for _, flt := range filtered {
		if entry.Rule.RecordingRule != nil {
			dep = c.usesVector(flt, entry.Rule.RecordingRule.Record.Value)
		}
		if entry.Rule.AlertingRule != nil {
			dep = c.usesAlert(flt, entry.Rule.AlertingRule.Alert.Value)
		}
		if dep != nil {
			var found bool
			for _, b := range broken {
				if b.kind == dep.kind && b.path == dep.path && b.line == dep.line && b.name == dep.name {
					found = true
					break
				}
			}
			if !found {
				broken = append(broken, dep)
			}
		}
	}

	if len(broken) == 0 {
		return problems
	}

	slices.SortStableFunc(broken, func(a, b *brokenDependency) int {
		return cmp.Or(
			cmp.Compare(a.path, b.path),
			cmp.Compare(a.line, b.line),
			cmp.Compare(a.name, b.name),
		)
	})

	var details strings.Builder
	details.WriteString("If you remove the ")
	details.WriteString(broken[0].kind)
	details.WriteString(" rule generating `")
	details.WriteString(broken[0].metric)
	details.WriteString("`, and there is no other source of this metric, then any other rule depending on it will break.\n")
	details.WriteString("List of found rules that are using `")
	details.WriteString(broken[0].metric)
	details.WriteString("`:\n\n")
	for _, b := range broken {
		details.WriteString("- `")
		details.WriteString(b.name)
		details.WriteString("` at `")
		details.WriteString(b.path)
		details.WriteRune(':')
		details.WriteString(strconv.Itoa(b.line))
		details.WriteString("`\n")
	}

	name := entry.Rule.NameNode()
	problems = append(problems, Problem{
		Anchor:   AnchorBefore,
		Lines:    name.Pos.Lines(),
		Reporter: c.Reporter(),
		Summary:  "rule results used by another rule",
		Details:  details.String(),
		Severity: Warning,
		Diagnostics: []diags.Diagnostic{
			{
				Message:     fmt.Sprintf("Metric generated by this rule is used by %d other rule(s).", len(broken)),
				Pos:         name.Pos,
				FirstColumn: 1,
				LastColumn:  len(name.Value),
				Kind:        diags.Issue,
			},
		},
	})

	return problems
}

func (c RuleDependencyCheck) checkCrossGroupDependency(entry *discovery.Entry, entries []*discovery.Entry) (problems []Problem) {
	if entry.Rule.RecordingRule == nil {
		// We only check recording rules here.
		// Alerting rules using recording rules in a different file are usually fine, there is still
		// a lag but that's usually ok and it's often impractical to change that.
		return problems
	}

	expr := entry.Rule.Expr()
	if expr.SyntaxError() != nil {
		return problems
	}

	filtered := nonRemovedEntries(entries)

	var deps []*crossGroupDep
	for _, src := range expr.Source() {
		src.WalkSources(func(s source.Source, _ *source.Join, _ *source.Unless) {
			vs, ok := source.MostOuterOperation[*promParser.VectorSelector](s)
			if !ok {
				return
			}
			for _, other := range filtered {
				if other.Rule.RecordingRule == nil {
					continue
				}
				if other.Rule.RecordingRule.Record.Value != vs.Name {
					continue
				}
				if other.Rule.Expr().SyntaxError() != nil {
					continue
				}
				if isStaticRecordingRule(other) {
					continue
				}
				if entry.Group != nil && other.Group != nil && entry.Group.Name.Value == other.Group.Name.Value {
					continue
				}
				vsPos := vs.PositionRange()
				deps = append(deps, &crossGroupDep{
					metric:      vs.Name,
					group:       other.Group.Name.Value,
					path:        other.Path.SymlinkTarget,
					line:        other.Rule.RecordingRule.Record.Pos.Lines().First,
					firstColumn: int(vsPos.Start) + 1,
					lastColumn:  int(vsPos.End),
				})
			}
		})
	}

	if len(deps) == 0 {
		return problems
	}

	slices.SortStableFunc(deps, func(a, b *crossGroupDep) int {
		return cmp.Or(
			cmp.Compare(a.path, b.path),
			cmp.Compare(a.line, b.line),
			cmp.Compare(a.metric, b.metric),
		)
	})

	deps = slices.CompactFunc(deps, func(a, b *crossGroupDep) bool {
		return a.metric == b.metric && a.group == b.group && a.path == b.path && a.line == b.line
	})

	for _, dep := range deps {
		details := "This rule uses `" + dep.metric + "` which is generated by a recording rule in the `" + dep.group + "` group at `" + dep.path + ":" + strconv.Itoa(dep.line) + "`.\n" +
			"Rules in different groups are evaluated independently. " +
			"This rule may use data from the previous evaluation cycle, introducing up to one evaluation interval of lag. " +
			"Moving both rules to the same group ensures they are evaluated together with consistent data."

		problems = append(problems, Problem{
			Anchor:   AnchorAfter,
			Lines:    expr.Value.Pos.Lines(),
			Reporter: c.Reporter(),
			Summary:  "rule uses metric produced by a recording rule from a different group",
			Details:  details,
			Severity: Warning,
			Diagnostics: []diags.Diagnostic{
				{
					Message:     "Rule uses `" + dep.metric + "` which is generated by a recording rule in a different group `" + dep.group + "`.",
					Pos:         expr.Value.Pos,
					FirstColumn: dep.firstColumn,
					LastColumn:  dep.lastColumn,
					Kind:        diags.Issue,
				},
			},
		})
	}

	return problems
}

func (c RuleDependencyCheck) usesVector(entry *discovery.Entry, name string) *brokenDependency {
	expr := entry.Rule.Expr()
	if expr.SyntaxError() != nil {
		return nil
	}

	for _, vs := range utils.HasVectorSelector(expr.Query()) {
		if vs.Name == name {
			return &brokenDependency{
				kind:   "recording",
				metric: name,
				path:   entry.Path.SymlinkTarget,
				line:   expr.Value.Pos.Lines().First,
				name:   entry.Rule.Name(),
			}
		}
	}

	return nil
}

func (c RuleDependencyCheck) usesAlert(entry *discovery.Entry, name string) *brokenDependency {
	expr := entry.Rule.Expr()
	if expr.SyntaxError() != nil {
		return nil
	}

	for _, vs := range utils.HasVectorSelector(expr.Query()) {
		if vs.Name != "ALERTS" && vs.Name != "ALERTS_FOR_STATE" {
			continue
		}
		for _, lm := range vs.LabelMatchers {
			if lm.Name == "alertname" && lm.Type == labels.MatchEqual && lm.Value == name {
				return &brokenDependency{
					kind:   "alerting",
					metric: vs.String(),
					path:   entry.Path.SymlinkTarget,
					line:   expr.Value.Pos.Lines().First,
					name:   entry.Rule.Name(),
				}
			}
		}
	}

	return nil
}

type brokenDependency struct {
	kind   string
	metric string
	path   string
	name   string
	line   int
}

type crossGroupDep struct {
	metric      string
	group       string
	path        string
	line        int
	firstColumn int
	lastColumn  int
}

func isStaticRecordingRule(entry *discovery.Entry) bool {
	for _, src := range entry.Rule.Expr().Source() {
		if !src.ReturnInfo.AlwaysReturns || !src.ReturnInfo.KnownReturn {
			return false
		}
	}
	return true
}

func nonRemovedEntries(src []*discovery.Entry) (dst []*discovery.Entry) {
	for _, entry := range src {
		if entry.State == discovery.Removed {
			continue
		}
		if entry.PathError != nil {
			continue
		}
		if entry.Rule.Error.Err != nil {
			continue
		}
		dst = append(dst, entry)
	}
	return dst
}
