package subscription

import (
	"encoding/json"
	"testing"

	"github.com/tidbcloud/alertstore/pkg/alert"
)

// The mode is what a channel opted in to. Nothing a filter leaves empty can
// hand it a stream it did not name — which is the whole reason the quiet ones
// are modes rather than filter values.
func TestModesDoNotLeakIntoEachOther(t *testing.T) {
	subs := []Subscription{
		{ID: "forward", Mode: ModeTopicForward, Filter: json.RawMessage(`{}`)},
		{ID: "card", Mode: ModeCardOnly, Filter: json.RawMessage(`{}`)},
		{ID: "event", Mode: ModeEvent, Filter: json.RawMessage(`{}`)},
		{ID: "customer", Mode: ModeCustomer, Filter: json.RawMessage(`{}`)},
	}

	cases := []struct {
		name  string
		modes []string
		want  []string
	}{
		{"an alert reaches only the alert modes", AlertModes, []string{"forward", "card"}},
		{"an internal observation reaches only event", []string{ModeEvent}, []string{"event"}},
		{"a customer notification reaches only customer", []string{ModeCustomer}, []string{"customer"}},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var got []string
			for _, sub := range subs {
				for _, mode := range tc.modes {
					if sub.Mode == mode {
						got = append(got, sub.ID)
					}
				}
			}
			if len(got) != len(tc.want) {
				t.Fatalf("matched %v, want %v", got, tc.want)
			}
			for i := range got {
				if got[i] != tc.want[i] {
					t.Errorf("matched %v, want %v", got, tc.want)
				}
			}
		})
	}
}

// Within a mode, the filter narrows as it always has: empty matches all.
func TestFilterNarrowsWithinAMode(t *testing.T) {
	f := &SubscriptionFilter{ClusterID: Eq("123")}
	if !f.Matches(Target{ClusterID: "123", Alertname: "anything"}) {
		t.Error("a named cluster should match its own alerts")
	}
	if f.Matches(Target{ClusterID: "456"}) {
		t.Error("a named cluster should not match another one")
	}
	if !(&SubscriptionFilter{}).Matches(Target{ClusterID: "456"}) {
		t.Error("an empty filter matches everything in its mode")
	}
}

// A team filters on the one thing that survives its clusters and alertnames
// changing: the label saying the team owns the rule.
func TestFilterByOncallTeamLabel(t *testing.T) {
	f := &SubscriptionFilter{Labels: map[string]*FieldMatch{"oncall_team": Eq("tikv", "pd")}}
	if !f.Matches(Target{Labels: map[string]string{"oncall_team": "tikv"}}) {
		t.Error("a named team should match its own alerts")
	}
	if !f.Matches(Target{Labels: map[string]string{"oncall_team": "pd"}}) {
		t.Error("every value the filter names should match")
	}
	if f.Matches(Target{Labels: map[string]string{"oncall_team": "tiflash"}}) {
		t.Error("a named team should not match another team's alerts")
	}
	// The label is optional and nothing is defaulted here, so an alert without
	// one belongs to no team as far as a filter is concerned.
	if f.Matches(Target{Labels: map[string]string{"severity": "critical"}}) {
		t.Error("an alert carrying no team label should match no named team")
	}
	if f.Matches(Target{}) {
		t.Error("an alert with no labels at all should match no named team")
	}
	if !(&SubscriptionFilter{}).Matches(Target{Labels: map[string]string{"oncall_team": "tikv"}}) {
		t.Error("a filter naming no label still matches everything in its mode")
	}
}

// Naming a second label narrows the filter, the way naming a second field
// does — it does not offer an alternative.
func TestLabelFilterNamesAreConjunctive(t *testing.T) {
	f := &SubscriptionFilter{Labels: map[string]*FieldMatch{
		"oncall_team": Eq("tikv"),
		"severity":    Eq("critical"),
	}}
	if !f.Matches(Target{Labels: map[string]string{"oncall_team": "tikv", "severity": "critical"}}) {
		t.Error("an alert satisfying every named label should match")
	}
	if f.Matches(Target{Labels: map[string]string{"oncall_team": "tikv", "severity": "warning"}}) {
		t.Error("an alert failing one named label should not match")
	}
	if f.Matches(Target{Labels: map[string]string{"oncall_team": "tikv"}}) {
		t.Error("an alert missing one named label should not match")
	}
}

// Component is the promoted attr, not the raw label: it is normalized on the
// way in, so a filter naming it should match whatever the alert was stored as.
func TestFilterByComponent(t *testing.T) {
	f := &SubscriptionFilter{Component: Eq("tikv", "pd")}
	if !f.Matches(Target{Component: "tikv"}) {
		t.Error("a named component should match its own alerts")
	}
	if f.Matches(Target{Component: "tiflash"}) {
		t.Error("a named component should not match another one")
	}
	if f.Matches(Target{}) {
		t.Error("an alert with no component should match no named component")
	}
	if !(&SubscriptionFilter{}).Matches(Target{Component: "tikv"}) {
		t.Error("a filter naming no component matches everything in its mode")
	}
}

// Labels sit alongside the promoted attrs rather than replacing them: the two
// narrow together.
func TestLabelFilterCombinesWithAttrFields(t *testing.T) {
	f := &SubscriptionFilter{
		ClusterID: Eq("123"),
		Labels:    map[string]*FieldMatch{"oncall_team": Eq("tikv")},
	}
	if !f.Matches(Target{ClusterID: "123", Labels: map[string]string{"oncall_team": "tikv"}}) {
		t.Error("satisfying both the attr and the label should match")
	}
	if f.Matches(Target{ClusterID: "456", Labels: map[string]string{"oncall_team": "tikv"}}) {
		t.Error("the attr field still constrains when a label matches")
	}
	if f.Matches(Target{ClusterID: "123", Labels: map[string]string{"oncall_team": "pd"}}) {
		t.Error("the label still constrains when the attr matches")
	}
}

// The filter reaches the store as JSON, so it has to survive a round trip
// under the name the API and the UI use — in every mode, and for mute rules,
// which have no mode at all.
func TestLabelFilterRoundTrip(t *testing.T) {
	const raw = `{"labels":{"oncall_team":["tikv"]}}`
	var f SubscriptionFilter
	if err := json.Unmarshal([]byte(raw), &f); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if !f.Matches(Target{Labels: map[string]string{"oncall_team": "tikv"}}) {
		t.Fatal("a decoded label filter should match that label")
	}
	for _, mode := range append(append([]string{}, AlertModes...), EventModes...) {
		if err := ValidateFilter(mode, []byte(raw)); err != nil {
			t.Errorf("ValidateFilter(%s, %s): %v", mode, raw, err)
		}
	}
	if err := ValidateMuteFilter([]byte(raw)); err != nil {
		t.Errorf("ValidateMuteFilter(%s): %v", raw, err)
	}
}

// A label entry that names nothing to match on would widen the filter instead
// of narrowing it, so it is refused rather than stored as a rule that quietly
// covers the fleet.
func TestLabelFilterRejectsEmptyEntries(t *testing.T) {
	invalid := []string{
		`{"labels":{"oncall_team":[]}}`,
		`{"labels":{"oncall_team":[""]}}`,
		`{"labels":{"":["tikv"]}}`,
		`{"labels":{"oncall_team":"tikv"}}`, // a bare string, not a value list
	}
	for _, raw := range invalid {
		if err := ValidateFilter(ModeTopicForward, []byte(raw)); err == nil {
			t.Errorf("ValidateFilter accepted %s, want error", raw)
		}
		if err := ValidateMuteFilter([]byte(raw)); err == nil {
			t.Errorf("ValidateMuteFilter accepted %s, want error", raw)
		}
	}
}

// A mute rule is stored from a filter nobody checked before; an unreadable one
// is skipped at match time and an empty one matches everything, so a typo has
// to be refused at write time.
func TestValidateMuteFilterChecksShape(t *testing.T) {
	if err := ValidateMuteFilter([]byte(`{}`)); err != nil {
		t.Errorf("an empty mute filter is valid (mute everything is a real ask): %v", err)
	}
	if err := ValidateMuteFilter([]byte(`{"cluster_ids":["123"]}`)); err == nil {
		t.Error("ValidateMuteFilter accepted an unknown field, want error")
	}
	if err := ValidateMuteFilter([]byte(`{"classification":["nonsense"]}`)); err == nil {
		t.Error("ValidateMuteFilter accepted an unknown classification, want error")
	}
	// This asserted the opposite once, on the reasoning that a rule has no mode
	// and so nothing to be consistent with. Refused now: a logging observation
	// opens no alert for any rule to suppress, and a no-op that looks
	// configured is the silence every other check here prevents.
	if err := ValidateMuteFilter([]byte(`{"classification":["logging"]}`)); err == nil {
		t.Error("ValidateMuteFilter accepted logging, which a mute rule can never match")
	}
	// Refused beside a classification mute does reach, rather than ignored: the
	// rule would work and quietly do less than it says.
	if err := ValidateMuteFilter([]byte(`{"classification":["page","logging"]}`)); err == nil {
		t.Error("ValidateMuteFilter accepted logging alongside page, want error")
	}
	for _, class := range []string{"page", "ticket"} {
		filter := `{"classification":["` + class + `"]}`
		if err := ValidateMuteFilter([]byte(filter)); err != nil {
			t.Errorf("ValidateMuteFilter(%s): %v", filter, err)
		}
	}
}

// A classification a mode cannot deliver would store a subscription that looks
// configured and delivers nothing, so it is refused at write time.
func TestValidateFilterClassificationByMode(t *testing.T) {
	valid := map[string][]string{
		ModeTopicForward: {`{"classification":["page","ticket"]}`},
		ModeCardOnly:     {`{"classification":["page"]}`},
		// Event mode delivers observations, and every alert is made of those —
		// including the page whose repeat firings the alert view hides.
		ModeEvent:    {`{"classification":["logging"]}`, `{"classification":["page"]}`, `{"classification":["page","ticket","logging"]}`},
		ModeCustomer: {`{"classification":["logging"],"alertname":["TiDBStorageAlmostFull"]}`},
	}
	for mode, filters := range valid {
		for _, filter := range filters {
			if err := ValidateFilter(mode, []byte(filter)); err != nil {
				t.Errorf("ValidateFilter(%s, %s): %v", mode, filter, err)
			}
		}
	}

	invalid := map[string]string{
		ModeTopicForward: `{"classification":["logging"]}`,
		ModeCardOnly:     `{"classification":["logging"]}`,
		// A customer-facing alert is forced to logging, so nothing else can
		// reach this mode.
		ModeCustomer: `{"classification":["ticket"]}`,
	}
	for mode, filter := range invalid {
		if err := ValidateFilter(mode, []byte(filter)); err == nil {
			t.Errorf("ValidateFilter(%s, %s) succeeded, want error", mode, filter)
		}
	}

	// An empty filter is valid in every mode: the mode already said what the
	// subscription is about.
	for _, mode := range append(append([]string{}, AlertModes...), EventModes...) {
		if err := ValidateFilter(mode, []byte(`{}`)); err != nil {
			t.Errorf("ValidateFilter(%s, {}): %v", mode, err)
		}
	}
}

// Visibility was a filter field before the modes were split apart. It is not
// one now — who an alert was written for is the mode — so a filter still
// carrying it is rejected rather than silently ignored.
func TestVisibilityIsNoLongerAFilterField(t *testing.T) {
	const filter = `{"classification":["logging"],"visibility":["external"]}`
	if err := ValidateFilter(ModeCustomer, []byte(filter)); err == nil {
		t.Error("ValidateFilter accepted a visibility filter, want error")
	}
	if !containsClass(modeClassifications[ModeCustomer], alert.ClassLogging) {
		t.Error("customer mode should deliver logging")
	}
}

func containsClass(list []string, value string) bool {
	for _, v := range list {
		if v == value {
			return true
		}
	}
	return false
}

// Whose account it is, as opposed to where it runs: a team covering EMEA
// customers is not the same set of alerts as one covering eu-central-1, and the
// two fields have to narrow independently.
func TestFilterByTenantProfile(t *testing.T) {
	f := &SubscriptionFilter{
		BizRegion:  Eq("EMEA"),
		TenantKind: Eq(alert.TenantKindExternal),
	}
	if !f.Matches(Target{BizRegion: "EMEA", TenantKind: "external"}) {
		t.Error("an alert satisfying both should match")
	}
	if f.Matches(Target{BizRegion: "NA", TenantKind: "external"}) {
		t.Error("the biz region still constrains when the kind matches")
	}
	if f.Matches(Target{BizRegion: "EMEA", TenantKind: "internal"}) {
		t.Error("the kind still constrains when the biz region matches")
	}
	// An unenriched alert — the ops portal was unreachable, or the alert carries
	// no tenant at all — names no region and no kind, so it matches neither.
	if f.Matches(Target{}) {
		t.Error("an alert with no tenant profile should match no named value")
	}
	if !(&SubscriptionFilter{}).Matches(Target{BizRegion: "EMEA", TenantKind: "external"}) {
		t.Error("a filter naming neither matches everything in its mode")
	}
}

// These two come from an enum, and its two spellings differ by field (`NA`,
// `internal`). A filter written in the other case is a filter someone believed
// they had configured, so it matches rather than silently delivering nothing.
func TestTenantProfileMatchesRegardlessOfCase(t *testing.T) {
	f := &SubscriptionFilter{BizRegion: Eq("emea"), TenantKind: Eq("Internal")}
	if !f.Matches(Target{BizRegion: "EMEA", TenantKind: "internal"}) {
		t.Error("case should not decide whether a tenant-profile filter matches")
	}

	// Only those two. A cluster ID or an alertname means exactly what it says.
	exact := &SubscriptionFilter{Alertname: Eq("tikvdown")}
	if exact.Matches(Target{Alertname: "TiKVDown"}) {
		t.Error("folding case on an alertname would widen filters nobody asked to widen")
	}
}

// The kinds are a closed pair, so a third word is refused at write time rather
// than stored as a rule that looks configured and matches nothing.
func TestValidateFilterChecksTenantKind(t *testing.T) {
	valid := json.RawMessage(`{"tenant_kind":["internal","External"],"biz_region":["EMEA"]}`)
	if err := ValidateFilter(ModeTopicForward, valid); err != nil {
		t.Errorf("both kinds in either case should be accepted: %v", err)
	}
	if err := ValidateMuteFilter(valid); err != nil {
		t.Errorf("a mute rule narrows over the same fields: %v", err)
	}

	invalid := json.RawMessage(`{"tenant_kind":["customer"]}`)
	if err := ValidateFilter(ModeTopicForward, invalid); err == nil {
		t.Error("a kind the ops portal does not have should be refused")
	}
	if err := ValidateMuteFilter(invalid); err == nil {
		t.Error("a mute rule with an unknown kind should be refused too")
	}

	// biz_region takes whatever it is given: the enum belongs to the ops portal,
	// which can add a region without telling alertstore.
	if err := ValidateMuteFilter(json.RawMessage(`{"biz_region":["MIDDLE_EARTH"]}`)); err != nil {
		t.Errorf("an unrecognised biz region should still be accepted: %v", err)
	}
}

// customer_type was a filter field with no source: designed with the service,
// never populated, so a subscription naming it matched nothing at all. It is
// the ops portal's segmentation, and it now narrows like the two fields it sits
// beside.
func TestFilterByCustomerType(t *testing.T) {
	f := &SubscriptionFilter{CustomerType: Eq("SKA", "KA")}
	if !f.Matches(Target{CustomerType: "KA"}) {
		t.Error("a named tier should match its own alerts")
	}
	if !f.Matches(Target{CustomerType: "ka"}) {
		t.Error("the tier is an enum, so its casing should not decide the match")
	}
	if f.Matches(Target{CustomerType: "SMB"}) {
		t.Error("a named tier should not match another one")
	}
	if f.Matches(Target{}) {
		t.Error("an alert with no tier should match no named tier")
	}
}
