package service

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"html"
	"log/slog"
	"os"
	"sort"
	"strings"
	"time"

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

type NotifyPreviewItem struct {
	TeamID                uint   `json:"team_id"`
	TeamDisplayName       string `json:"team_display_name"`
	TeamKey               string `json:"team_key"`
	GroupName             string `json:"group_name"`
	CurrentEngineerName   string `json:"current_engineer_name"`
	CurrentEngineerEmail  string `json:"current_engineer_email"`
	PreviousEngineerName  string `json:"previous_engineer_name"`
	PreviousEngineerEmail string `json:"previous_engineer_email"`
	CurrentStartTime      int64  `json:"current_start_time"`
	CurrentEndTime        int64  `json:"current_end_time"`
	CurrentOverlay        int    `json:"current_overlay"`
	PreviousFound         bool   `json:"previous_found"`
	CurrentNoOncall       bool   `json:"current_no_oncall"`
	SkipReason            string `json:"skip_reason,omitempty"`
	TextLine              string `json:"text_line"`
}

type NotifyPreviewResult struct {
	GroupName   string              `json:"group_name"`
	GeneratedAt int64               `json:"generated_at"`
	StartTime   int64               `json:"start_time"`
	EndTime     int64               `json:"end_time"`
	Items       []NotifyPreviewItem `json:"items"`
	MessageText string              `json:"message_text"`
}

func engineerDisplayLabel(name, email string) string {
	label := strings.TrimSpace(name)
	if label != "" {
		return label
	}
	return strings.TrimSpace(email)
}

func isLikelyTestTeam(cat models.Team) bool {
	name := strings.ToLower(strings.TrimSpace(cat.Name))
	display := strings.ToLower(strings.TrimSpace(cat.DisplayName))
	desc := strings.ToLower(strings.TrimSpace(cat.Description))
	if strings.Contains(name, "e2e") || strings.Contains(name, "test") || strings.Contains(name, "schedtest_") {
		return true
	}
	if strings.Contains(display, "e2e") || strings.Contains(display, "test") {
		return true
	}
	if strings.Contains(desc, "e2e") || strings.Contains(desc, "test") {
		return true
	}
	return false
}

type ocPerson struct{ name, email string }

const notifyLookbackSeconds = int64(30 * 24 * 3600)

var weeklyRosterMentionUserIDsByEmail = ResolveFeishuOpenIDsByEmail

// effectiveOnCallAt lists everyone effectively on call (primary) at `at`, plus
// the bounding shift window.
func (s *ScheduleService) effectiveOnCallAt(teamID uint, at int64) (people []ocPerson, segStart, segEnd int64) {
	rows, err := s.GetEffectiveScheduleV2([]uint{teamID}, at-notifyLookbackSeconds, at+1, false, false)
	if err != nil {
		return nil, 0, 0
	}
	for _, r := range rows {
		st, _ := r["start_time"].(int64)
		en, _ := r["end_time"].(int64)
		if st <= at && en > at {
			people = append(people, ocPerson{name: fmt.Sprintf("%v", r["engineer_name"]), email: fmt.Sprintf("%v", r["engineer_email"])})
			segStart, segEnd = st, en
		}
	}
	return people, segStart, segEnd
}

// effectiveOnCallEndingAt lists the people whose shift ends exactly at boundary
// (i.e., the previous on-call handing over).
func (s *ScheduleService) effectiveOnCallEndingAt(teamID uint, boundary int64) (people []ocPerson) {
	if boundary <= 0 {
		return nil
	}
	rows, err := s.GetEffectiveScheduleV2([]uint{teamID}, boundary-notifyLookbackSeconds, boundary+1, false, false)
	if err != nil {
		return nil
	}
	for _, r := range rows {
		if en, _ := r["end_time"].(int64); en == boundary {
			people = append(people, ocPerson{name: fmt.Sprintf("%v", r["engineer_name"]), email: fmt.Sprintf("%v", r["engineer_email"])})
		}
	}
	return people
}

func joinOnCallLabels(people []ocPerson) string {
	labels := make([]string, 0, len(people))
	for _, p := range people {
		if l := engineerDisplayLabel(p.name, p.email); l != "" {
			labels = append(labels, l)
		}
	}
	if len(labels) == 0 {
		return "NO-ONCALL"
	}
	return strings.Join(labels, ", ")
}

func joinOnCallEmails(people []ocPerson) string {
	emails := make([]string, 0, len(people))
	for _, p := range people {
		if e := strings.TrimSpace(p.email); e != "" {
			emails = append(emails, e)
		}
	}
	return strings.Join(emails, ", ")
}

func weeklyRosterLabel(p ocPerson, userIDsByEmail map[string]string) string {
	label := engineerDisplayLabel(p.name, p.email)
	email := normalizeEmail(p.email)
	if userID := strings.TrimSpace(userIDsByEmail[email]); userID != "" {
		return fmt.Sprintf(`<at user_id="%s">%s</at>`, html.EscapeString(userID), html.EscapeString(label))
	}
	return label
}

func joinWeeklyRosterLabels(people []ocPerson, userIDsByEmail map[string]string) string {
	labels := make([]string, 0, len(people))
	for _, p := range people {
		if l := weeklyRosterLabel(p, userIDsByEmail); l != "" {
			labels = append(labels, l)
		}
	}
	if len(labels) == 0 {
		return "NO-ONCALL"
	}
	return strings.Join(labels, " → ")
}

func (s *ScheduleService) BuildFeishuNotifyPreview(groupName string, atTime int64, skipSameSubstitute bool, excludeTestTeams bool) (*NotifyPreviewResult, error) {
	if atTime <= 0 {
		atTime = time.Now().Unix()
	}

	var teams []models.Team
	if err := db.DB.Where("is_disabled = ?", false).
		Order("display_name ASC, name ASC, id ASC").Find(&teams).Error; err != nil {
		return nil, err
	}
	teams = filterTeamsByGroup(teams, groupName)
	// When a specific group is requested, label items with it; otherwise fall back
	// to each team's primary group.
	requestedGroup := strings.TrimSpace(groupName)
	groupLabel := func(t models.Team) string {
		if requestedGroup != "" {
			return requestedGroup
		}
		return primaryGroup(t)
	}

	items := make([]NotifyPreviewItem, 0, len(teams))
	var minStart int64
	var maxEnd int64

	for _, cat := range teams {
		if excludeTestTeams && isLikelyTestTeam(cat) {
			continue
		}

		displayName := strings.TrimSpace(cat.DisplayName)
		if displayName == "" {
			displayName = strings.TrimSpace(cat.Name)
		}

		cur, curStart, curEnd := s.effectiveOnCallAt(cat.ID, atTime)
		if len(cur) == 0 {
			items = append(items, NotifyPreviewItem{
				TeamID:          cat.ID,
				TeamDisplayName: displayName,
				TeamKey:         cat.Name,
				GroupName:       groupLabel(cat),
				CurrentNoOncall: true,
				TextLine:        fmt.Sprintf("%s: @NO-ONCALL substitutes @UNKNOWN", displayName),
			})
			continue
		}

		if minStart == 0 || curStart < minStart {
			minStart = curStart
		}
		if curEnd > maxEnd {
			maxEnd = curEnd
		}

		prev := s.effectiveOnCallEndingAt(cat.ID, curStart)
		currentLabel := joinOnCallLabels(cur)
		previousFound := len(prev) > 0
		previousLabel := "UNKNOWN"
		if previousFound {
			previousLabel = joinOnCallLabels(prev)
		}

		if skipSameSubstitute && previousFound && currentLabel == previousLabel {
			continue
		}

		items = append(items, NotifyPreviewItem{
			TeamID:                cat.ID,
			TeamDisplayName:       displayName,
			TeamKey:               cat.Name,
			GroupName:             groupLabel(cat),
			CurrentEngineerName:   currentLabel,
			CurrentEngineerEmail:  joinOnCallEmails(cur),
			PreviousEngineerName:  previousLabel,
			PreviousEngineerEmail: joinOnCallEmails(prev),
			CurrentStartTime:      curStart,
			CurrentEndTime:        curEnd,
			PreviousFound:         previousFound,
			CurrentNoOncall:       false,
			TextLine:              fmt.Sprintf("%s: @%s substitutes @%s", displayName, currentLabel, previousLabel),
		})
	}

	sort.Slice(items, func(i, j int) bool {
		if items[i].GroupName != items[j].GroupName {
			return items[i].GroupName < items[j].GroupName
		}
		if items[i].TeamDisplayName != items[j].TeamDisplayName {
			return items[i].TeamDisplayName < items[j].TeamDisplayName
		}
		return items[i].TeamID < items[j].TeamID
	})

	loc := time.FixedZone("UTC+8", 8*3600)
	startText := time.Unix(atTime, 0).In(loc).Format("2006-01-02 15:04:05")
	endText := startText
	if minStart > 0 {
		startText = time.Unix(minStart, 0).In(loc).Format("2006-01-02 15:04:05")
	}
	if maxEnd > 0 {
		endText = time.Unix(maxEnd, 0).In(loc).Format("2006-01-02 15:04:05")
	}

	lines := make([]string, 0, len(items)+3)
	lines = append(lines, fmt.Sprintf("Fire Brigade substitution[%s --- %s]", startText, endText))
	for _, item := range items {
		lines = append(lines, item.TextLine)
	}
	lines = append(lines, "We would appreciate it if you could help to hand over the unresolved oncall cases.")
	lines = append(lines, "Shift Schedule: "+strings.TrimRight(strings.TrimSpace(getScheduleBaseURL()), "/")+"/")

	return &NotifyPreviewResult{
		GroupName:   strings.TrimSpace(groupName),
		GeneratedAt: atTime,
		StartTime:   minStart,
		EndTime:     maxEnd,
		Items:       items,
		MessageText: strings.Join(lines, "\n"),
	}, nil
}

func (s *ScheduleService) SendFeishuNotifyPreview(groupName string, atTime int64, skipSameSubstitute bool, excludeTestTeams bool, recipients []string, recipientEmails []string) (*NotifyPreviewResult, error) {
	preview, err := s.BuildFeishuNotifyPreview(groupName, atTime, skipSameSubstitute, excludeTestTeams)
	if err != nil {
		return nil, err
	}
	resolved := make([]string, 0, len(recipients)+len(recipientEmails))
	seen := map[string]struct{}{}
	for _, recipient := range recipients {
		userID := strings.TrimSpace(recipient)
		if userID == "" {
			continue
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		resolved = append(resolved, userID)
	}
	for _, email := range recipientEmails {
		userID, err := ResolveFeishuUserIDByEmail(email)
		if err != nil {
			return nil, err
		}
		if _, ok := seen[userID]; ok {
			continue
		}
		seen[userID] = struct{}{}
		resolved = append(resolved, userID)
	}
	for _, userID := range resolved {
		if err := SendFeishuMessage(userID, preview.MessageText); err != nil {
			return nil, err
		}
	}
	return preview, nil
}

// weeklyRosterGroup is the group whose teams go in the weekly post (case-insensitive, deploy-overridable).
func weeklyRosterGroup() string {
	if v := strings.TrimSpace(os.Getenv("ONCALL_WEEKLY_GROUP")); v != "" {
		return v
	}
	return "ONCALL"
}

const weeklyRosterScheduleURL = "https://oncall.pingcap.net"

type weeklyRosterLine struct {
	displayName string
	people      []ocPerson
}

type weeklyRosterData struct {
	fromText       string
	toText         string
	teamLines      []weeklyRosterLine
	userIDsByEmail map[string]string
}

// weeklyRosterTeams lists the non-test teams in the configured weekly-roster
// group, in priority order — the team eligibility logic shared by every
// weekly roster renderer (full-week text/card, and the live current-status card).
func weeklyRosterTeams() ([]models.Team, error) {
	var teams []models.Team
	if err := db.DB.Where("is_disabled = ?", false).Find(&teams).Error; err != nil {
		return nil, err
	}
	teams = filterTeamsByGroup(teams, weeklyRosterGroup())
	sortTeamsByPriority(teams)

	filtered := make([]models.Team, 0, len(teams))
	for _, cat := range teams {
		if !isLikelyTestTeam(cat) {
			filtered = append(filtered, cat)
		}
	}
	return filtered, nil
}

// buildWeeklyRosterData fetches and shapes the data needed to render the
// weekly roster post for [from, to), shared by both the plain-text and card
// renderers so the team/eligibility/mention-resolution logic lives in one place.
func (s *ScheduleService) buildWeeklyRosterData(from, to int64) (weeklyRosterData, error) {
	teams, err := weeklyRosterTeams()
	if err != nil {
		return weeklyRosterData{}, err
	}

	teamLines := make([]weeklyRosterLine, 0, len(teams))
	emails := make([]string, 0)
	for _, cat := range teams {
		displayName := strings.TrimSpace(cat.DisplayName)
		if displayName == "" {
			displayName = strings.TrimSpace(cat.Name)
		}
		people := s.weeklyRosterPeople(cat.ID, from, to)
		teamLines = append(teamLines, weeklyRosterLine{displayName: displayName, people: people})
		for _, person := range people {
			if email := normalizeEmail(person.email); email != "" {
				emails = append(emails, email)
			}
		}
	}

	userIDsByEmail := map[string]string{}
	if len(emails) > 0 {
		resolved, err := weeklyRosterMentionUserIDsByEmail(emails)
		if err != nil {
			slog.Warn("failed to resolve feishu mentions for weekly roster; using plain names", "error", err)
		} else {
			userIDsByEmail = resolved
		}
	}

	loc := time.FixedZone("UTC+8", 8*3600)
	return weeklyRosterData{
		fromText:       time.Unix(from, 0).In(loc).Format("Mon Jan 2 15:04"),
		toText:         time.Unix(to, 0).In(loc).Format("Mon Jan 2 15:04"),
		teamLines:      teamLines,
		userIDsByEmail: userIDsByEmail,
	}, nil
}

// BuildWeeklyRosterMessage renders the weekly post for [from, to): one line per
// team in the group, primary on-call joined by " → " in shift order (single name
// = no handover; repeats de-duplicated keeping first appearance).
func (s *ScheduleService) BuildWeeklyRosterMessage(from, to int64) (string, error) {
	data, err := s.buildWeeklyRosterData(from, to)
	if err != nil {
		return "", err
	}

	lines := make([]string, 0, len(data.teamLines)+6)
	lines = append(lines, "📅 On-Call Schedule (starts tomorrow)")
	lines = append(lines, fmt.Sprintf("%s — %s (UTC+8)", data.fromText, data.toText))
	lines = append(lines, "Full schedule → "+weeklyRosterScheduleURL)
	lines = append(lines, "")

	for _, line := range data.teamLines {
		lines = append(lines, fmt.Sprintf("• %s:   %s", line.displayName, joinWeeklyRosterLabels(line.people, data.userIDsByEmail)))
	}

	lines = append(lines, "")
	lines = append(lines, "Need a change? Please comment in this topic AND update it in the on-call system.")

	return strings.Join(lines, "\n"), nil
}

func weeklyRosterCardLabel(p ocPerson, userIDsByEmail map[string]string) string {
	label := engineerDisplayLabel(p.name, p.email)
	email := normalizeEmail(p.email)
	if userID := strings.TrimSpace(userIDsByEmail[email]); userID != "" {
		return fmt.Sprintf(`<at id="%s"></at>`, userID)
	}
	return escapeLarkMD(label)
}

func joinWeeklyRosterCardLabels(people []ocPerson, userIDsByEmail map[string]string) string {
	labels := make([]string, 0, len(people))
	for _, p := range people {
		if l := weeklyRosterCardLabel(p, userIDsByEmail); l != "" {
			labels = append(labels, l)
		}
	}
	if len(labels) == 0 {
		return "NO-ONCALL"
	}
	return strings.Join(labels, " → ")
}

var larkMDReplacer = strings.NewReplacer(
	"\\", "\\\\",
	"*", "\\*",
	"_", "\\_",
	"`", "\\`",
	"~", "\\~",
	"[", "\\[",
	"]", "\\]",
)

// escapeLarkMD escapes markdown special characters in text destined for a
// Feishu card's lark_md element, since team display names are admin-entered.
func escapeLarkMD(s string) string {
	return larkMDReplacer.Replace(s)
}

// weeklyRosterCardHash returns a stable hash of the logical roster content
// (not the marshaled card JSON, whose map key order is non-deterministic), so
// callers can cheaply detect "nothing changed since last send/patch."
func weeklyRosterCardHash(data weeklyRosterData) string {
	var b strings.Builder
	b.WriteString(data.fromText)
	b.WriteString("|")
	b.WriteString(data.toText)
	for _, line := range data.teamLines {
		b.WriteString("|")
		b.WriteString(line.displayName)
		b.WriteString(":")
		b.WriteString(joinWeeklyRosterCardLabels(line.people, data.userIDsByEmail))
	}
	sum := sha256.Sum256([]byte(b.String()))
	return hex.EncodeToString(sum[:])
}

// BuildWeeklyRosterCard renders the weekly post for [from, to) as a Feishu
// interactive card, along with a content hash for change detection. Mentions
// use Feishu's self-closing <at id="..."></at> tag, which resolves to the
// person's Feishu directory display name (unlike the plain-text version,
// which shows the name on file in this service).
func (s *ScheduleService) BuildWeeklyRosterCard(from, to int64) (map[string]interface{}, string, error) {
	data, err := s.buildWeeklyRosterData(from, to)
	if err != nil {
		return nil, "", err
	}

	elements := make([]interface{}, 0, len(data.teamLines)+4)
	elements = append(elements, map[string]interface{}{
		"tag": "div",
		"text": map[string]interface{}{
			"tag":     "lark_md",
			"content": fmt.Sprintf("%s — %s (UTC+8)", data.fromText, data.toText),
		},
	})
	elements = append(elements, map[string]interface{}{
		"tag": "div",
		"text": map[string]interface{}{
			"tag":     "lark_md",
			"content": "Full schedule → " + weeklyRosterScheduleURL,
		},
	})
	elements = append(elements, map[string]interface{}{"tag": "hr"})

	for _, line := range data.teamLines {
		elements = append(elements, map[string]interface{}{
			"tag": "div",
			"text": map[string]interface{}{
				"tag":     "lark_md",
				"content": fmt.Sprintf("**%s**: %s", escapeLarkMD(line.displayName), joinWeeklyRosterCardLabels(line.people, data.userIDsByEmail)),
			},
		})
	}

	elements = append(elements, map[string]interface{}{"tag": "hr"})
	elements = append(elements, map[string]interface{}{
		"tag": "note",
		"elements": []interface{}{
			map[string]interface{}{
				"tag":     "plain_text",
				"content": "Need a change? Please comment in this topic AND update it in the on-call system.",
			},
		},
	})

	card := map[string]interface{}{
		"config": map[string]interface{}{
			"wide_screen_mode": true,
			"update_multi":     true,
		},
		"header": map[string]interface{}{
			"template": "blue",
			"title": map[string]interface{}{
				"tag":     "plain_text",
				"content": "📅 On-Call Schedule",
			},
		},
		"elements": elements,
	}

	return card, weeklyRosterCardHash(data), nil
}

// weeklyRosterNames lists a team's primary on-call over [from, to) in shift
// order, de-duplicated, joined by " → "; "NO-ONCALL" when nobody is scheduled.
func (s *ScheduleService) weeklyRosterNames(teamID uint, from, to int64) string {
	return joinWeeklyRosterLabels(s.weeklyRosterPeople(teamID, from, to), nil)
}

// weeklyRosterCurrentAndOthers splits a team's [from, to) roster into who is
// on call right now (any shift covering `now`) and everyone else scheduled
// sometime in the window but not right now. Each person appears in exactly one
// bucket, de-duplicated across their (possibly multiple, wrap-around) shifts,
// preserving first-appearance order within each bucket.
func (s *ScheduleService) weeklyRosterCurrentAndOthers(teamID uint, from, to, now int64) (current, others []ocPerson) {
	rows, err := s.GetEffectiveScheduleV2([]uint{teamID}, from, to, false, false)
	if err != nil {
		return nil, nil
	}
	sort.SliceStable(rows, func(i, j int) bool {
		li, _ := rows[i]["start_time"].(int64)
		lj, _ := rows[j]["start_time"].(int64)
		return li < lj
	})

	type personState struct {
		person    ocPerson
		isCurrent bool
	}
	order := make([]string, 0, len(rows))
	state := make(map[string]*personState, len(rows))
	for _, r := range rows {
		email := strings.TrimSpace(strings.ToLower(fmt.Sprintf("%v", r["engineer_email"])))
		name := strings.TrimSpace(fmt.Sprintf("%v", r["engineer_name"]))
		key := email
		if key == "" {
			key = name
		}
		if key == "" {
			continue
		}
		ps, ok := state[key]
		if !ok {
			ps = &personState{person: ocPerson{name: name, email: email}}
			state[key] = ps
			order = append(order, key)
		}
		st, _ := r["start_time"].(int64)
		en, _ := r["end_time"].(int64)
		if st <= now && now < en {
			ps.isCurrent = true
		}
	}

	for _, key := range order {
		ps := state[key]
		if ps.isCurrent {
			current = append(current, ps.person)
		} else {
			others = append(others, ps.person)
		}
	}
	return current, others
}

func joinPlainRosterLabels(people []ocPerson) string {
	labels := make([]string, 0, len(people))
	for _, p := range people {
		if l := engineerDisplayLabel(p.name, p.email); l != "" {
			labels = append(labels, escapeLarkMD(l))
		}
	}
	return strings.Join(labels, ", ")
}

type weeklyRosterLiveTeamLine struct {
	displayName string
	current     []ocPerson
	others      []ocPerson
}

type weeklyRosterLiveData struct {
	fromText       string
	toText         string
	teamLines      []weeklyRosterLiveTeamLine
	userIDsByEmail map[string]string
}

// buildWeeklyRosterLiveData fetches and shapes the data needed to render the
// live "who's on call right now" card for [from, to) at instant `now`.
func (s *ScheduleService) buildWeeklyRosterLiveData(from, to, now int64) (weeklyRosterLiveData, error) {
	teams, err := weeklyRosterTeams()
	if err != nil {
		return weeklyRosterLiveData{}, err
	}

	teamLines := make([]weeklyRosterLiveTeamLine, 0, len(teams))
	emails := make([]string, 0)
	for _, cat := range teams {
		displayName := strings.TrimSpace(cat.DisplayName)
		if displayName == "" {
			displayName = strings.TrimSpace(cat.Name)
		}
		current, others := s.weeklyRosterCurrentAndOthers(cat.ID, from, to, now)
		teamLines = append(teamLines, weeklyRosterLiveTeamLine{displayName: displayName, current: current, others: others})
		for _, p := range current {
			if email := normalizeEmail(p.email); email != "" {
				emails = append(emails, email)
			}
		}
		for _, p := range others {
			if email := normalizeEmail(p.email); email != "" {
				emails = append(emails, email)
			}
		}
	}

	userIDsByEmail := map[string]string{}
	if len(emails) > 0 {
		resolved, err := weeklyRosterMentionUserIDsByEmail(emails)
		if err != nil {
			slog.Warn("failed to resolve feishu mentions for weekly roster live card; using plain names", "error", err)
		} else {
			userIDsByEmail = resolved
		}
	}

	loc := time.FixedZone("UTC+8", 8*3600)
	return weeklyRosterLiveData{
		fromText:       time.Unix(from, 0).In(loc).Format("Mon Jan 2 15:04"),
		toText:         time.Unix(to, 0).In(loc).Format("Mon Jan 2 15:04"),
		teamLines:      teamLines,
		userIDsByEmail: userIDsByEmail,
	}, nil
}

// weeklyRosterLiveCardHash returns a stable hash of the logical live-card
// content (current + others per team), for cheap change detection.
func weeklyRosterLiveCardHash(data weeklyRosterLiveData) string {
	var b strings.Builder
	b.WriteString(data.fromText)
	b.WriteString("|")
	b.WriteString(data.toText)
	for _, line := range data.teamLines {
		b.WriteString("|")
		b.WriteString(line.displayName)
		b.WriteString(":cur:")
		b.WriteString(joinWeeklyRosterCardLabels(line.current, data.userIDsByEmail))
		b.WriteString(":oth:")
		b.WriteString(joinPlainRosterLabels(line.others))
	}
	sum := sha256.Sum256([]byte(b.String()))
	return hex.EncodeToString(sum[:])
}

// BuildWeeklyRosterLiveCard renders "who's on call right now" for [from, to)
// at instant `now`, as a Feishu interactive card, along with a content hash
// for change detection. Each team shows who's currently on call (@ mentioned)
// plus, if anyone else in the team is scheduled sometime this week but isn't
// on call right now, a plain-text follow-up line naming them (not @ mentioned,
// since this card is patched in place every 30 minutes and re-pinging on every
// patch would be noisy).
func (s *ScheduleService) BuildWeeklyRosterLiveCard(from, to, now int64) (map[string]interface{}, string, error) {
	data, err := s.buildWeeklyRosterLiveData(from, to, now)
	if err != nil {
		return nil, "", err
	}

	elements := make([]interface{}, 0, len(data.teamLines)+3)
	elements = append(elements, map[string]interface{}{
		"tag": "div",
		"text": map[string]interface{}{
			"tag":     "lark_md",
			"content": fmt.Sprintf("Week of %s — %s (UTC+8)", data.fromText, data.toText),
		},
	})
	elements = append(elements, map[string]interface{}{
		"tag": "div",
		"text": map[string]interface{}{
			"tag":     "lark_md",
			"content": "Full schedule → " + weeklyRosterScheduleURL,
		},
	})
	elements = append(elements, map[string]interface{}{"tag": "hr"})

	for _, line := range data.teamLines {
		content := fmt.Sprintf("**%s**: %s", escapeLarkMD(line.displayName), joinWeeklyRosterCardLabels(line.current, data.userIDsByEmail))
		if len(line.others) > 0 {
			content += "\n" + joinPlainRosterLabels(line.others) + " also on call this week but not now"
		}
		elements = append(elements, map[string]interface{}{
			"tag": "div",
			"text": map[string]interface{}{
				"tag":     "lark_md",
				"content": content,
			},
		})
	}

	card := map[string]interface{}{
		"config": map[string]interface{}{
			"wide_screen_mode": true,
			"update_multi":     true,
		},
		"header": map[string]interface{}{
			"template": "turquoise",
			"title": map[string]interface{}{
				"tag":     "plain_text",
				"content": "🟢 On-Call Now",
			},
		},
		"elements": elements,
	}

	return card, weeklyRosterLiveCardHash(data), nil
}

func (s *ScheduleService) weeklyRosterPeople(teamID uint, from, to int64) []ocPerson {
	rows, err := s.GetEffectiveScheduleV2([]uint{teamID}, from, to, false, false)
	if err != nil {
		return nil
	}
	sort.SliceStable(rows, func(i, j int) bool {
		li, _ := rows[i]["start_time"].(int64)
		lj, _ := rows[j]["start_time"].(int64)
		return li < lj
	})

	people := make([]ocPerson, 0, len(rows))
	seen := make(map[string]bool, len(rows))
	for _, r := range rows {
		email := strings.TrimSpace(strings.ToLower(fmt.Sprintf("%v", r["engineer_email"])))
		name := strings.TrimSpace(fmt.Sprintf("%v", r["engineer_name"]))
		key := email
		if key == "" {
			key = name
		}
		if key == "" || seen[key] {
			continue
		}
		seen[key] = true
		people = append(people, ocPerson{name: name, email: email})
	}
	return people
}

func getScheduleBaseURL() string {
	if v := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(os.Getenv("ONCALL_SCHEDULE_URL")), "/")); v != "" {
		return v
	}
	if v := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(os.Getenv("ONCALL_FRONTEND_URL")), "/")); v != "" {
		return v
	}
	return "http://dev.schedule.devops.pingcap.net"
}
