package preview

import (
	"testing"
	"time"

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

var now = time.Date(2026, 8, 12, 12, 0, 0, 0, time.UTC)

func windows() []Window {
	return []Window{
		{Label: "168h", Span: 168 * time.Hour},
		{Label: "24h", Span: 24 * time.Hour},
	}
}

func req(mode string, f subscription.SubscriptionFilter) Request {
	return Request{Mode: mode, Filter: f, Windows: windows()}
}

// cand builds a candidate that matches an all-empty filter, aged by age.
func cand(age time.Duration, alertname string) candidate {
	return candidate{
		id:     alertname,
		at:     now.Add(-age),
		state:  "open",
		target: subscription.Target{Alertname: alertname},
		owned:  true,
	}
}

func windowCount(t *testing.T, res *Result, label string) WindowCount {
	t.Helper()
	for _, w := range res.Windows {
		if w.Window == label {
			return w
		}
	}
	t.Fatalf("no window %q in result", label)
	return WindowCount{}
}

func TestCorpusFollowsMode(t *testing.T) {
	alertModes := []string{subscription.ModeTopicForward, subscription.ModeCardOnly, ModeMute}
	for _, mode := range alertModes {
		corpus, err := corpusFor(mode)
		if err != nil {
			t.Fatalf("corpusFor(%q): %v", mode, err)
		}
		if corpus != CorpusAlert {
			t.Errorf("corpusFor(%q) = %q, want %q", mode, corpus, CorpusAlert)
		}
	}
	for _, mode := range []string{subscription.ModeEvent, subscription.ModeCustomer} {
		corpus, err := corpusFor(mode)
		if err != nil {
			t.Fatalf("corpusFor(%q): %v", mode, err)
		}
		if corpus != CorpusEvent {
			t.Errorf("corpusFor(%q) = %q, want %q", mode, corpus, CorpusEvent)
		}
	}
	for _, mode := range []string{"", "muted", "TOPIC_FORWARD"} {
		if _, err := corpusFor(mode); err == nil {
			t.Errorf("corpusFor(%q) succeeded, want error", mode)
		}
	}
}

func TestWindowsCountByAge(t *testing.T) {
	candidates := []candidate{
		cand(time.Hour, "A"),
		cand(20*time.Hour, "A"),
		cand(3*24*time.Hour, "B"),
		cand(6*24*time.Hour, "B"),
	}
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		candidates, now.Add(-168*time.Hour), now)

	if got := windowCount(t, res, "24h").Count; got != 2 {
		t.Errorf("24h count = %d, want 2", got)
	}
	if got := windowCount(t, res, "168h").Count; got != 4 {
		t.Errorf("168h count = %d, want 4", got)
	}
}

// A shadow alert is stored but never fanned out, so counting one would promise
// a delivery that will not happen. Mute is checked before ownership is decided.
func TestUnownedAlertsCountForMuteOnly(t *testing.T) {
	shadow := cand(time.Hour, "A")
	shadow.owned = false
	candidates := []candidate{cand(time.Hour, "A"), shadow}

	forward := aggregate(req(subscription.ModeTopicForward, subscription.SubscriptionFilter{}),
		CorpusAlert, candidates, now.Add(-168*time.Hour), now)
	if got := windowCount(t, forward, "24h").Count; got != 1 {
		t.Errorf("topic_forward 24h count = %d, want 1 (shadow alert excluded)", got)
	}

	muted := aggregate(req(ModeMute, subscription.SubscriptionFilter{}),
		CorpusAlert, candidates, now.Add(-168*time.Hour), now)
	if got := windowCount(t, muted, "24h").Count; got != 2 {
		t.Errorf("mute 24h count = %d, want 2 (shadow alert included)", got)
	}
}

// Visibility splits the event corpus exactly as the fan-out splits it.
func TestEventVisibilitySplitsModes(t *testing.T) {
	external := cand(time.Hour, "A")
	external.external = true
	candidates := []candidate{cand(time.Hour, "A"), external}

	internal := aggregate(req(subscription.ModeEvent, subscription.SubscriptionFilter{}),
		CorpusEvent, candidates, now.Add(-168*time.Hour), now)
	if got := windowCount(t, internal, "24h").Count; got != 1 {
		t.Errorf("event 24h count = %d, want 1", got)
	}

	customer := aggregate(req(subscription.ModeCustomer, subscription.SubscriptionFilter{}),
		CorpusEvent, candidates, now.Add(-168*time.Hour), now)
	if got := windowCount(t, customer, "24h").Count; got != 1 {
		t.Errorf("customer 24h count = %d, want 1", got)
	}
	if customer.Latest[0].ID != external.id || customer.Latest[0].At != external.at {
		t.Errorf("customer preview matched the internal observation, not the external one")
	}
}

// The production matcher applies, including fields no pushdown covers.
func TestFilterAppliedToUnpushedFields(t *testing.T) {
	teamed := cand(time.Hour, "A")
	teamed.target.Labels = map[string]string{"oncall_team": "tikv"}
	other := cand(2*time.Hour, "A")
	other.target.Labels = map[string]string{"oncall_team": "pd"}

	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{
		Labels: map[string]*subscription.FieldMatch{"oncall_team": subscription.Eq("tikv")},
	}), CorpusAlert, []candidate{teamed, other}, now.Add(-168*time.Hour), now)

	if got := windowCount(t, res, "24h").Count; got != 1 {
		t.Errorf("label-filtered 24h count = %d, want 1", got)
	}
	if res.MatchesEverything {
		t.Error("MatchesEverything is true for a filter naming a label")
	}
}

func TestMatchesEverythingOnEmptyFilter(t *testing.T) {
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		[]candidate{cand(time.Hour, "A")}, now.Add(-168*time.Hour), now)
	if !res.MatchesEverything {
		t.Error("MatchesEverything is false for a filter naming nothing")
	}
}

// A truncated scan holds the newest rows, so a window inside it stays exact and
// only the ones reaching past it become floors.
func TestTruncationMarksOnlyUncoveredWindows(t *testing.T) {
	// Fills the scan limit reaching 48h back: the 24h window is inside what was
	// read, the week is not.
	candidates := make([]candidate, ScanLimit)
	for i := range candidates {
		age := time.Duration(int64(48*time.Hour) * int64(i) / int64(ScanLimit-1))
		candidates[i] = cand(age, "A")
	}
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		candidates, now.Add(-168*time.Hour), now)

	if !res.Truncated {
		t.Fatal("Truncated is false at the scan limit")
	}
	if w := windowCount(t, res, "24h"); !w.Exact {
		t.Error("24h window marked inexact though the scan reached 48h back")
	}
	if w := windowCount(t, res, "168h"); w.Exact {
		t.Error("168h window marked exact though the scan stopped at 48h ago")
	}
}

func TestUntruncatedScanIsExact(t *testing.T) {
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		[]candidate{cand(time.Hour, "A")}, now.Add(-168*time.Hour), now)
	for _, w := range res.Windows {
		if !w.Exact {
			t.Errorf("window %s marked inexact on a complete scan", w.Window)
		}
	}
}

func TestDailyHistogramBuckets(t *testing.T) {
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert, []candidate{
		cand(time.Hour, "A"),      // today
		cand(2*time.Hour, "A"),    // today
		cand(25*time.Hour, "B"),   // yesterday
		cand(6*24*time.Hour, "C"), // the oldest day drawn
	}, now.Add(-168*time.Hour), now)

	if len(res.Daily) != HistogramDays {
		t.Fatalf("daily length = %d, want %d", len(res.Daily), HistogramDays)
	}
	if got := res.Daily[HistogramDays-1]; got.Date != "2026-08-12" || got.Count != 2 {
		t.Errorf("today = %+v, want 2026-08-12 with 2", got)
	}
	if got := res.Daily[HistogramDays-2]; got.Date != "2026-08-11" || got.Count != 1 {
		t.Errorf("yesterday = %+v, want 2026-08-11 with 1", got)
	}
	if got := res.Daily[0]; got.Date != "2026-08-06" || got.Count != 1 {
		t.Errorf("oldest day = %+v, want 2026-08-06 with 1", got)
	}
}

// The breakdown catches a plausible total made of the wrong things.
func TestTopAlertnamesRanked(t *testing.T) {
	var candidates []candidate
	for i := 0; i < 5; i++ {
		candidates = append(candidates, cand(time.Duration(i)*time.Minute, "Noisy"))
	}
	candidates = append(candidates, cand(time.Hour, "Quiet"), cand(2*time.Hour, "Another"))

	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		candidates, now.Add(-168*time.Hour), now)

	if len(res.TopAlertnames) != 3 {
		t.Fatalf("top alertnames = %+v, want 3 entries", res.TopAlertnames)
	}
	if res.TopAlertnames[0] != (NameCount{Alertname: "Noisy", Count: 5}) {
		t.Errorf("first = %+v, want Noisy with 5", res.TopAlertnames[0])
	}
	// Equal counts sort by name, so two previews agree.
	if res.TopAlertnames[1].Alertname != "Another" || res.TopAlertnames[2].Alertname != "Quiet" {
		t.Errorf("ties not ordered by name: %+v", res.TopAlertnames)
	}
}

func TestTopAlertnamesCapped(t *testing.T) {
	var candidates []candidate
	for i := 0; i < TopAlertnameCount+3; i++ {
		candidates = append(candidates, cand(time.Duration(i)*time.Minute, string(rune('A'+i))))
	}
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		candidates, now.Add(-168*time.Hour), now)
	if len(res.TopAlertnames) != TopAlertnameCount {
		t.Errorf("top alertnames = %d entries, want %d", len(res.TopAlertnames), TopAlertnameCount)
	}
}

// Newest-first, so the samples are the most recent matches.
func TestLatestSamplesAreNewestMatches(t *testing.T) {
	candidates := []candidate{
		cand(time.Hour, "A"), cand(2*time.Hour, "B"), cand(3*time.Hour, "C"), cand(4*time.Hour, "D"),
	}
	res := aggregate(req(ModeMute, subscription.SubscriptionFilter{}), CorpusAlert,
		candidates, now.Add(-168*time.Hour), now)

	if len(res.Latest) != SampleCount {
		t.Fatalf("latest = %d, want %d", len(res.Latest), SampleCount)
	}
	for i, want := range []string{"A", "B", "C"} {
		if res.Latest[i].ID != want {
			t.Errorf("latest[%d].ID = %q, want %q", i, res.Latest[i].ID, want)
		}
	}
	if res.Latest[0].State != "open" || res.Latest[0].Alertname != "A" {
		t.Errorf("sample carries no context: %+v", res.Latest[0])
	}
}

// Only columns whose SQL comparison means what Matches means are pushed down.
func TestPushdownCoversExactIndexedColumnsOnly(t *testing.T) {
	where, args := pushdown(subscription.SubscriptionFilter{
		Region:         subscription.Eq("us-west-1"),
		Classification: subscription.Eq("page", "ticket"),
		Alertname:      subscription.Eq("A"),
		ClusterID:      subscription.Eq("c1"),
		TenantID:       subscription.Eq("t1"),
		TenantKind:     subscription.Eq("internal"),
		BizRegion:      subscription.Eq("NA"),
		CustomerType:   subscription.Eq("KA"),
		Component:      subscription.Eq("tikv"),
		Labels:         map[string]*subscription.FieldMatch{"oncall_team": subscription.Eq("tikv")},
	})

	want := " AND region IN (?) AND classification IN (?,?) AND alertname IN (?)" +
		" AND cluster_id IN (?) AND tenant_id IN (?)"
	if where != want {
		t.Errorf("pushdown SQL = %q, want %q", where, want)
	}
	if len(args) != 6 {
		t.Errorf("pushdown args = %d, want 6", len(args))
	}
}

// Only the plain equal operator becomes an IN. A regex or negated field has no
// pushdown that stays a subset of Matches, so it is held back and matched in Go.
func TestPushdownSkipsNonEqualOperators(t *testing.T) {
	where, args := pushdown(subscription.SubscriptionFilter{
		Region:    subscription.Eq("us-west-1"),
		Alertname: &subscription.FieldMatch{Op: subscription.OpRe, Values: []string{"TiKV_.*"}},
		ClusterID: &subscription.FieldMatch{Op: subscription.OpNe, Values: []string{"c9"}},
	})
	want := " AND region IN (?)"
	if where != want {
		t.Errorf("pushdown SQL = %q, want %q (alertname + cluster_id held back)", where, want)
	}
	if len(args) != 1 {
		t.Errorf("pushdown args = %d, want 1", len(args))
	}
}

func TestPushdownEmptyFilter(t *testing.T) {
	where, args := pushdown(subscription.SubscriptionFilter{})
	if where != "" || len(args) != 0 {
		t.Errorf("empty filter pushed down %q with %d args, want no narrowing", where, len(args))
	}
}

// The histogram is always drawn, so a short window still reads a week.
func TestScanSpanCoversHistogramAndLongestWindow(t *testing.T) {
	week := time.Duration(HistogramDays) * 24 * time.Hour
	if got := scanSpan([]Window{{Label: "1h", Span: time.Hour}}); got != week {
		t.Errorf("scanSpan(1h) = %s, want %s", got, week)
	}
	if got := scanSpan([]Window{{Label: "720h", Span: 720 * time.Hour}}); got != 720*time.Hour {
		t.Errorf("scanSpan(720h) = %s, want 720h", got)
	}
	if got := scanSpan(nil); got != week {
		t.Errorf("scanSpan(nil) = %s, want %s", got, week)
	}
}
