package service

import (
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/tidbcloud/aisupport/services/oncall/db"
	"github.com/tidbcloud/aisupport/services/oncall/models"

	"gorm.io/driver/sqlite"
	"gorm.io/gorm"
)

func setupWeeklyTestDB(t *testing.T) {
	t.Helper()
	var err error
	db.DB, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
	if err != nil {
		t.Fatalf("failed to connect sqlite: %v", err)
	}
	if err := db.DB.AutoMigrate(&models.Team{}, &models.Engineer{}, &models.ShiftInterval{},
		&models.TeamLayer{}, &models.RealizedShift{}, &models.NotificationLog{}); err != nil {
		t.Fatalf("failed to migrate: %v", err)
	}
}

func mkEngineers(t *testing.T, names map[string]string) {
	t.Helper()
	for email, name := range names {
		if err := db.DB.Create(&models.Engineer{Name: name, Email: email}).Error; err != nil {
			t.Fatalf("create engineer: %v", err)
		}
	}
}

func TestBuildWeeklyRosterMessage(t *testing.T) {
	setupWeeklyTestDB(t)
	withWeeklyRosterMentionResolver(t, func(emails []string) (map[string]string, error) {
		return map[string]string{}, nil
	})
	const day = int64(24 * 3600)

	mkEngineers(t, map[string]string{
		"a@pingcap.com": "Alice",
		"b@pingcap.com": "Bob",
		"c@pingcap.com": "Carol",
		"d@pingcap.com": "Dave",
	})

	rot := models.Team{Name: "rot", DisplayName: "Team Rot", GroupNames: models.StringList{"ONCALL"}, SortPriority: 1}
	dedup := models.Team{Name: "dedup", DisplayName: "Team Dedup", GroupNames: models.StringList{"oncall"}, SortPriority: 2}
	empty := models.Team{Name: "empty", DisplayName: "Team Empty", GroupNames: models.StringList{"ONCALL"}, SortPriority: 3}
	other := models.Team{Name: "other", DisplayName: "Team Other", GroupNames: models.StringList{"PLATFORM"}, SortPriority: 4}
	tst := models.Team{Name: "schedtest_x", DisplayName: "Team Test", GroupNames: models.StringList{"ONCALL"}, SortPriority: 5}
	for _, c := range []*models.Team{&rot, &dedup, &empty, &other, &tst} {
		db.DB.Create(c)
	}

	// Daily rotation a -> b -> c.
	db.DB.Create(&models.TeamLayer{
		TeamID: rot.ID, Priority: 1, People: models.StringList{"a@pingcap.com", "b@pingcap.com", "c@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	})
	// Daily rotation a -> b -> a: same person twice, must de-dup keeping first slot.
	db.DB.Create(&models.TeamLayer{
		TeamID: dedup.ID, Priority: 1, People: models.StringList{"a@pingcap.com", "b@pingcap.com", "a@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	})
	// other + tst get a layer too, to prove they are still excluded (group / test filter).
	db.DB.Create(&models.TeamLayer{
		TeamID: other.ID, Priority: 1, People: models.StringList{"d@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	})
	db.DB.Create(&models.TeamLayer{
		TeamID: tst.ID, Priority: 1, People: models.StringList{"d@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	})

	s := &ScheduleService{}
	from := monday00()
	to := monday00() + 3*day // covers slots a, b, c
	msg, err := s.BuildWeeklyRosterMessage(from, to)
	if err != nil {
		t.Fatalf("BuildWeeklyRosterMessage: %v", err)
	}

	checks := []string{
		"📅 On-Call Schedule (starts tomorrow)",
		"Full schedule → https://oncall.pingcap.net",
		"• Team Rot:   Alice → Bob → Carol",
		"• Team Dedup:   Alice → Bob",
		"• Team Empty:   NO-ONCALL",
		"Need a change? Please comment in this topic AND update it in the on-call system.",
	}
	for _, c := range checks {
		if !strings.Contains(msg, c) {
			t.Fatalf("message missing %q\n---\n%s", c, msg)
		}
	}
	if strings.Contains(msg, "Team Other") {
		t.Fatalf("non-ONCALL group team should be excluded:\n%s", msg)
	}
	if strings.Contains(msg, "Team Test") {
		t.Fatalf("test team should be excluded:\n%s", msg)
	}
	// Priority order: Rot before Dedup before Empty.
	if idxRot, idxEmpty := strings.Index(msg, "Team Rot"), strings.Index(msg, "Team Empty"); idxRot > idxEmpty {
		t.Fatalf("teams not ordered by priority:\n%s", msg)
	}
}

func TestBuildWeeklyRosterMessageMentionsOncallers(t *testing.T) {
	setupWeeklyTestDB(t)
	withWeeklyRosterMentionResolver(t, func(emails []string) (map[string]string, error) {
		return map[string]string{
			"a@pingcap.com": "ou_alice",
			"b@pingcap.com": "ou_bob",
		}, nil
	})
	const day = int64(24 * 3600)

	mkEngineers(t, map[string]string{
		"a@pingcap.com": `A&<"lice`,
		"b@pingcap.com": "Bob",
		"c@pingcap.com": "Carol",
	})
	team := models.Team{Name: "rot", DisplayName: "Team Rot", GroupNames: models.StringList{"ONCALL"}, SortPriority: 1}
	if err := db.DB.Create(&team).Error; err != nil {
		t.Fatalf("create team: %v", err)
	}
	if err := db.DB.Create(&models.TeamLayer{
		TeamID: team.ID, Priority: 1, People: models.StringList{"a@pingcap.com", "b@pingcap.com", "c@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	}).Error; err != nil {
		t.Fatalf("create layer: %v", err)
	}

	msg, err := (&ScheduleService{}).BuildWeeklyRosterMessage(monday00(), monday00()+3*day)
	if err != nil {
		t.Fatalf("BuildWeeklyRosterMessage: %v", err)
	}

	checks := []string{
		`• Team Rot:   <at user_id="ou_alice">A&amp;&lt;&#34;lice</at> → <at user_id="ou_bob">Bob</at> → Carol`,
		"Need a change? Please comment in this topic AND update it in the on-call system.",
	}
	for _, c := range checks {
		if !strings.Contains(msg, c) {
			t.Fatalf("message missing %q\n---\n%s", c, msg)
		}
	}
}

func TestBuildWeeklyRosterMessageFallsBackWhenMentionResolutionFails(t *testing.T) {
	setupWeeklyTestDB(t)
	withWeeklyRosterMentionResolver(t, func(emails []string) (map[string]string, error) {
		return nil, fmt.Errorf("feishu unavailable")
	})
	const day = int64(24 * 3600)

	mkEngineers(t, map[string]string{"a@pingcap.com": "Alice"})
	team := models.Team{Name: "rot", DisplayName: "Team Rot", GroupNames: models.StringList{"ONCALL"}, SortPriority: 1}
	if err := db.DB.Create(&team).Error; err != nil {
		t.Fatalf("create team: %v", err)
	}
	if err := db.DB.Create(&models.TeamLayer{
		TeamID: team.ID, Priority: 1, People: models.StringList{"a@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	}).Error; err != nil {
		t.Fatalf("create layer: %v", err)
	}

	msg, err := (&ScheduleService{}).BuildWeeklyRosterMessage(monday00(), monday00()+day)
	if err != nil {
		t.Fatalf("BuildWeeklyRosterMessage: %v", err)
	}
	if !strings.Contains(msg, "• Team Rot:   Alice") {
		t.Fatalf("message should fall back to plain name\n---\n%s", msg)
	}
	if strings.Contains(msg, "<at user_id=") {
		t.Fatalf("message should not include mention tags after resolver failure\n---\n%s", msg)
	}
}

func TestWeeklyRosterDue(t *testing.T) {
	at := func(y int, mo time.Month, d, h, mi int) time.Time {
		return time.Date(y, mo, d, h, mi, 0, 0, weeklyRosterLoc)
	}
	cases := []struct {
		name string
		now  time.Time
		due  bool
	}{
		{"thursday 11:00 exactly", at(2024, 1, 4, 11, 0), true},
		{"thursday within grace", at(2024, 1, 4, 16, 0), true},
		{"thursday past grace", at(2024, 1, 4, 18, 0), false},
		{"thursday before 11", at(2024, 1, 4, 10, 0), false},
		{"friday", at(2024, 1, 5, 12, 0), false},
		{"monday", at(2024, 1, 1, 11, 0), false},
		{"next thursday 11:00", at(2024, 1, 11, 11, 0), true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if _, due := weeklyRosterDue(c.now); due != c.due {
				t.Fatalf("weeklyRosterDue(%s) = %v, want %v", c.now, due, c.due)
			}
		})
	}
}

func TestWeeklyRosterLiveDue(t *testing.T) {
	at := func(y int, mo time.Month, d, h, mi int) time.Time {
		return time.Date(y, mo, d, h, mi, 0, 0, weeklyRosterLoc)
	}
	cases := []struct {
		name string
		now  time.Time
		due  bool
	}{
		{"friday 11:05 exactly", at(2024, 1, 5, 11, 5), true},
		{"friday within grace", at(2024, 1, 5, 16, 0), true},
		{"friday past grace", at(2024, 1, 5, 18, 0), false},
		{"friday before 11:05", at(2024, 1, 5, 11, 0), false},
		{"thursday", at(2024, 1, 4, 12, 0), false},
		{"monday", at(2024, 1, 1, 11, 5), false},
		{"next friday 11:05", at(2024, 1, 12, 11, 5), true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if _, due := weeklyRosterLiveDue(c.now); due != c.due {
				t.Fatalf("weeklyRosterLiveDue(%s) = %v, want %v", c.now, due, c.due)
			}
		})
	}
}

func withWeeklyRosterMentionResolver(t *testing.T, resolver func([]string) (map[string]string, error)) {
	t.Helper()
	orig := weeklyRosterMentionUserIDsByEmail
	weeklyRosterMentionUserIDsByEmail = resolver
	t.Cleanup(func() {
		weeklyRosterMentionUserIDsByEmail = orig
	})
}

func TestRunWeeklyRosterIfDue_IdempotentAndRetry(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}
	thursday := time.Date(2024, 1, 4, 11, 0, 0, 0, weeklyRosterLoc)

	var sent []string
	send := func(chatID, msg string) error {
		sent = append(sent, chatID+"|"+msg)
		return nil
	}

	// Not due (Monday) -> no send, no row.
	if err := s.runWeeklyRosterIfDue(time.Date(2024, 1, 1, 11, 0, 0, 0, weeklyRosterLoc), "oc_chat", send); err != nil {
		t.Fatalf("not-due run: %v", err)
	}
	if len(sent) != 0 {
		t.Fatalf("should not send when not due, got %d", len(sent))
	}

	// Due -> sends once.
	if err := s.runWeeklyRosterIfDue(thursday, "oc_chat", send); err != nil {
		t.Fatalf("due run: %v", err)
	}
	if len(sent) != 1 {
		t.Fatalf("expected 1 send, got %d", len(sent))
	}

	// Same week again (e.g. next tick / another replica) -> no duplicate.
	if err := s.runWeeklyRosterIfDue(thursday.Add(time.Hour), "oc_chat", send); err != nil {
		t.Fatalf("repeat run: %v", err)
	}
	if len(sent) != 1 {
		t.Fatalf("expected no duplicate send, got %d", len(sent))
	}

	var rows int64
	db.DB.Model(&models.NotificationLog{}).Where("kind = ?", weeklyRosterKind).Count(&rows)
	if rows != 1 {
		t.Fatalf("expected 1 notification log row, got %d", rows)
	}
}

func TestRunWeeklyRosterIfDue_ReleasesClaimOnSendFailure(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}
	thursday := time.Date(2024, 1, 4, 11, 0, 0, 0, weeklyRosterLoc)

	calls := 0
	send := func(chatID, msg string) error {
		calls++
		if calls == 1 {
			return fmt.Errorf("transient feishu error")
		}
		return nil
	}

	if err := s.runWeeklyRosterIfDue(thursday, "oc_chat", send); err == nil {
		t.Fatalf("expected error on first (failing) send")
	}
	var rows int64
	db.DB.Model(&models.NotificationLog{}).Where("kind = ?", weeklyRosterKind).Count(&rows)
	if rows != 0 {
		t.Fatalf("claim should be released after send failure, found %d rows", rows)
	}

	// Retry within grace succeeds and persists the claim.
	if err := s.runWeeklyRosterIfDue(thursday.Add(time.Hour), "oc_chat", send); err != nil {
		t.Fatalf("retry run: %v", err)
	}
	db.DB.Model(&models.NotificationLog{}).Where("kind = ?", weeklyRosterKind).Count(&rows)
	if rows != 1 {
		t.Fatalf("expected 1 row after successful retry, got %d", rows)
	}
}

func TestRefreshWeeklyRosterCard_NoOpWhenNothingSentYet(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}

	patched := 0
	patch := func(messageID string, card map[string]interface{}) error {
		patched++
		return nil
	}

	if err := s.refreshWeeklyRosterCardWithPatch(time.Now(), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if patched != 0 {
		t.Fatalf("expected no patch when nothing sent yet, got %d", patched)
	}
}

func TestRefreshWeeklyRosterCard_NoOpWhenMessageIDMissing(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}
	thursday := time.Date(2024, 1, 4, 11, 0, 0, 0, weeklyRosterLoc)

	if err := db.DB.Create(&models.NotificationLog{Kind: weeklyRosterKind, RefTime: thursday.Unix(), SentAt: thursday.Unix()}).Error; err != nil {
		t.Fatalf("seed row: %v", err)
	}

	patched := 0
	patch := func(messageID string, card map[string]interface{}) error {
		patched++
		return nil
	}

	if err := s.refreshWeeklyRosterCardWithPatch(thursday.Add(time.Hour), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if patched != 0 {
		t.Fatalf("expected no patch when message_id missing, got %d", patched)
	}
}

func TestRefreshWeeklyRosterCard_OutsideWindowIsNoOp(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}
	thursday := time.Date(2024, 1, 4, 11, 0, 0, 0, weeklyRosterLoc)
	msgID := "om_1"

	if err := db.DB.Create(&models.NotificationLog{
		Kind: weeklyRosterKind, RefTime: thursday.Unix(), SentAt: thursday.Unix(), MessageID: &msgID,
	}).Error; err != nil {
		t.Fatalf("seed row: %v", err)
	}

	patched := 0
	patch := func(messageID string, card map[string]interface{}) error {
		patched++
		return nil
	}

	// Before the covered window starts (Friday 11:00 the next day).
	if err := s.refreshWeeklyRosterCardWithPatch(thursday.Add(2*time.Hour), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	// Well past the covered window + grace.
	if err := s.refreshWeeklyRosterCardWithPatch(thursday.AddDate(0, 0, 30), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if patched != 0 {
		t.Fatalf("expected no patch outside the covered window, got %d", patched)
	}
}

func TestRefreshWeeklyRosterCard_PatchesOnChangeAndSkipsWhenUnchanged(t *testing.T) {
	setupWeeklyTestDB(t)
	withWeeklyRosterMentionResolver(t, func(emails []string) (map[string]string, error) {
		return map[string]string{}, nil
	})
	const day = int64(24 * 3600)
	thursday := time.Date(2024, 1, 4, 11, 0, 0, 0, weeklyRosterLoc)
	from := thursday.AddDate(0, 0, 1).Unix()

	mkEngineers(t, map[string]string{"a@pingcap.com": "Alice", "b@pingcap.com": "Bob"})
	team := models.Team{Name: "rot", DisplayName: "Team Rot", GroupNames: models.StringList{"ONCALL"}, SortPriority: 1}
	if err := db.DB.Create(&team).Error; err != nil {
		t.Fatalf("create team: %v", err)
	}
	if err := db.DB.Create(&models.TeamLayer{
		TeamID: team.ID, Priority: 1, People: models.StringList{"a@pingcap.com"},
		RotateEverySeconds: 100 * day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: from,
	}).Error; err != nil {
		t.Fatalf("create layer: %v", err)
	}

	msgID := "om_1"
	if err := db.DB.Create(&models.NotificationLog{
		Kind: weeklyRosterKind, RefTime: thursday.Unix(), SentAt: thursday.Unix(), MessageID: &msgID,
	}).Error; err != nil {
		t.Fatalf("seed row: %v", err)
	}

	s := &ScheduleService{}
	now := time.Unix(from, 0).Add(2 * time.Hour) // inside the covered window (starts the day after send)

	var patchedCards []map[string]interface{}
	patch := func(messageID string, card map[string]interface{}) error {
		patchedCards = append(patchedCards, card)
		return nil
	}

	// First refresh: no content_hash on the row yet, so it must patch once.
	if err := s.refreshWeeklyRosterCardWithPatch(now, patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if len(patchedCards) != 1 {
		t.Fatalf("expected 1 patch on first refresh, got %d", len(patchedCards))
	}
	if !strings.Contains(fmt.Sprintf("%v", patchedCards[0]), "Alice") {
		t.Fatalf("patched card should mention Alice: %v", patchedCards[0])
	}

	var row models.NotificationLog
	if err := db.DB.Where("kind = ?", weeklyRosterKind).First(&row).Error; err != nil {
		t.Fatalf("reload row: %v", err)
	}
	if row.ContentHash == nil || *row.ContentHash == "" {
		t.Fatalf("expected content_hash to be persisted after patch")
	}
	if row.PatchedAt == nil {
		t.Fatalf("expected patched_at to be set")
	}

	// Second refresh with unchanged schedule: must not patch again.
	if err := s.refreshWeeklyRosterCardWithPatch(now.Add(30*time.Minute), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if len(patchedCards) != 1 {
		t.Fatalf("expected no additional patch when unchanged, got %d total", len(patchedCards))
	}

	// Simulate a mid-week swap: Bob takes over from Alice for the whole window.
	db.DB.Model(&models.TeamLayer{}).Where("team_id = ?", team.ID).
		Update("people", models.StringList{"b@pingcap.com"})

	if err := s.refreshWeeklyRosterCardWithPatch(now.Add(time.Hour), patch); err != nil {
		t.Fatalf("refresh after swap: %v", err)
	}
	if len(patchedCards) != 2 {
		t.Fatalf("expected a second patch after schedule change, got %d", len(patchedCards))
	}
	if !strings.Contains(fmt.Sprintf("%v", patchedCards[1]), "Bob") {
		t.Fatalf("patched card should mention Bob after swap: %v", patchedCards[1])
	}
}

func TestRunWeeklyRosterLiveIfDue_IdempotentAndRetry(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}
	friday := time.Date(2024, 1, 5, 11, 5, 0, 0, weeklyRosterLoc)

	var sent []string
	send := func(chatID string, card map[string]interface{}) (string, error) {
		sent = append(sent, chatID)
		return fmt.Sprintf("om_live_%d", len(sent)), nil
	}

	// Not due (Thursday) -> no send, no row.
	if err := s.runWeeklyRosterLiveIfDue(time.Date(2024, 1, 4, 11, 5, 0, 0, weeklyRosterLoc), "oc_chat", send); err != nil {
		t.Fatalf("not-due run: %v", err)
	}
	if len(sent) != 0 {
		t.Fatalf("should not send when not due, got %d", len(sent))
	}

	// Due -> sends once.
	if err := s.runWeeklyRosterLiveIfDue(friday, "oc_chat", send); err != nil {
		t.Fatalf("due run: %v", err)
	}
	if len(sent) != 1 {
		t.Fatalf("expected 1 send, got %d", len(sent))
	}

	// Same week again (e.g. next tick / another replica) -> no duplicate.
	if err := s.runWeeklyRosterLiveIfDue(friday.Add(time.Hour), "oc_chat", send); err != nil {
		t.Fatalf("repeat run: %v", err)
	}
	if len(sent) != 1 {
		t.Fatalf("expected no duplicate send, got %d", len(sent))
	}

	var row models.NotificationLog
	if err := db.DB.Where("kind = ?", weeklyRosterLiveKind).First(&row).Error; err != nil {
		t.Fatalf("expected 1 notification log row: %v", err)
	}
	if row.MessageID == nil || *row.MessageID != "om_live_1" {
		t.Fatalf("expected message_id to be persisted, got %v", row.MessageID)
	}
}

func TestRefreshWeeklyRosterLiveCard_NoOpWhenNothingSentYet(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}

	patched := 0
	patch := func(messageID string, card map[string]interface{}) error {
		patched++
		return nil
	}

	if err := s.refreshWeeklyRosterLiveCardWithPatch(time.Now(), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if patched != 0 {
		t.Fatalf("expected no patch when nothing sent yet, got %d", patched)
	}
}

func TestRefreshWeeklyRosterLiveCard_OutsideWindowIsNoOp(t *testing.T) {
	setupWeeklyTestDB(t)
	s := &ScheduleService{}
	friday := time.Date(2024, 1, 5, 11, 5, 0, 0, weeklyRosterLoc)
	msgID := "om_live_1"

	if err := db.DB.Create(&models.NotificationLog{
		Kind: weeklyRosterLiveKind, RefTime: friday.Unix(), SentAt: friday.Unix(), MessageID: &msgID,
	}).Error; err != nil {
		t.Fatalf("seed row: %v", err)
	}

	patched := 0
	patch := func(messageID string, card map[string]interface{}) error {
		patched++
		return nil
	}

	// Well past the covered window + grace.
	if err := s.refreshWeeklyRosterLiveCardWithPatch(friday.AddDate(0, 0, 30), patch); err != nil {
		t.Fatalf("refresh: %v", err)
	}
	if patched != 0 {
		t.Fatalf("expected no patch outside the covered window, got %d", patched)
	}
}

func TestRefreshWeeklyRosterLiveCard_TracksHandoverAsTimeAdvances(t *testing.T) {
	setupWeeklyTestDB(t)
	withWeeklyRosterMentionResolver(t, func(emails []string) (map[string]string, error) {
		return map[string]string{}, nil
	})
	const day = int64(24 * 3600)
	friday := time.Date(2024, 1, 5, 11, 5, 0, 0, weeklyRosterLoc)
	from := friday.Add(-5 * time.Minute).Unix() // window start: Friday 11:00

	mkEngineers(t, map[string]string{"a@pingcap.com": "Alice", "b@pingcap.com": "Bob"})
	team := models.Team{Name: "rot", DisplayName: "Team Rot", GroupNames: models.StringList{"ONCALL"}, SortPriority: 1}
	if err := db.DB.Create(&team).Error; err != nil {
		t.Fatalf("create team: %v", err)
	}
	// Daily rotation starting at window open: Alice day 1, Bob day 2, ...
	if err := db.DB.Create(&models.TeamLayer{
		TeamID: team.ID, Priority: 1, People: models.StringList{"a@pingcap.com", "b@pingcap.com"},
		RotateEverySeconds: day, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: from,
	}).Error; err != nil {
		t.Fatalf("create layer: %v", err)
	}

	msgID := "om_live_1"
	if err := db.DB.Create(&models.NotificationLog{
		Kind: weeklyRosterLiveKind, RefTime: friday.Unix(), SentAt: friday.Unix(), MessageID: &msgID,
	}).Error; err != nil {
		t.Fatalf("seed row: %v", err)
	}

	s := &ScheduleService{}
	var patchedCards []map[string]interface{}
	patch := func(messageID string, card map[string]interface{}) error {
		patchedCards = append(patchedCards, card)
		return nil
	}

	// Day 1 (Alice's shift): first refresh must patch, showing Alice as current.
	day1 := time.Unix(from, 0).Add(time.Hour)
	if err := s.refreshWeeklyRosterLiveCardWithPatch(day1, patch); err != nil {
		t.Fatalf("refresh day1: %v", err)
	}
	if len(patchedCards) != 1 {
		t.Fatalf("expected 1 patch on day1, got %d", len(patchedCards))
	}
	rendered := fmt.Sprintf("%v", patchedCards[0])
	if !strings.Contains(rendered, "Alice") {
		t.Fatalf("day1 card should show Alice as current: %s", rendered)
	}
	if !strings.Contains(rendered, "Bob") || !strings.Contains(rendered, "also on call this week but not now") {
		t.Fatalf("day1 card should list Bob as also-on-call-but-not-now: %s", rendered)
	}

	// Same day, no boundary crossed -> no additional patch.
	if err := s.refreshWeeklyRosterLiveCardWithPatch(day1.Add(2*time.Hour), patch); err != nil {
		t.Fatalf("refresh day1 again: %v", err)
	}
	if len(patchedCards) != 1 {
		t.Fatalf("expected no additional patch within the same shift, got %d total", len(patchedCards))
	}

	// Day 2 (handover to Bob): must patch again, now showing Bob as current.
	day2 := time.Unix(from, 0).AddDate(0, 0, 1).Add(time.Hour)
	if err := s.refreshWeeklyRosterLiveCardWithPatch(day2, patch); err != nil {
		t.Fatalf("refresh day2: %v", err)
	}
	if len(patchedCards) != 2 {
		t.Fatalf("expected a second patch after handover, got %d", len(patchedCards))
	}
	rendered2 := fmt.Sprintf("%v", patchedCards[1])
	if !strings.Contains(rendered2, "Bob") {
		t.Fatalf("day2 card should show Bob as current after handover: %s", rendered2)
	}
	if !strings.Contains(rendered2, "Alice") || !strings.Contains(rendered2, "also on call this week but not now") {
		t.Fatalf("day2 card should list Alice as also-on-call-but-not-now: %s", rendered2)
	}
}
