package service

import (
	"strings"
	"testing"

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

func TestNormalizeSlotForms(t *testing.T) {
	cases := []struct{ in, want string }{
		{"  A@X.com ", "a@x.com"},
		{"placeholder", placeholderToken},
		{"  Placeholder  ", placeholderToken},
		{"PLACEHOLDER:TBD", placeholderPrefix + "TBD"},
		{"placeholder:  on leave ", placeholderPrefix + "on leave"},
		{"placeholder:", placeholderToken}, // an empty label is just a placeholder
		{"   ", ""},
	}
	for _, c := range cases {
		if got := normalizeSlot(c.in); got != c.want {
			t.Fatalf("normalizeSlot(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

// A placeholder takes its turn in the cycle and puts nobody on call.
func TestPlaceholderSlotIsNoOncall(t *testing.T) {
	layer := models.TeamLayer{
		People:             models.StringList{"a@x.com", "placeholder:TBD", "b@x.com"},
		RotateEverySeconds: week,
		OnCallAtOnce:       1,
		ActiveMode:         "always",
		StartsAt:           monday00(),
	}
	want := [][]string{{"a@x.com"}, {}, {"b@x.com"}, {"a@x.com"}}
	for i, w := range want {
		got := layerOnCallAt(layer, monday00()+int64(i)*week+3600)
		if !emailsEqual(got, w) {
			t.Fatalf("week %d: got %v want %v", i, got, w)
		}
	}
}

// Placeholders are positions, not people: repeating one keeps two distinct slots.
func TestPlaceholdersAreNotDeduped(t *testing.T) {
	layer := models.TeamLayer{
		People: models.StringList{"a@x.com", "placeholder", "a@x.com", "placeholder"},
	}
	if got := layerSlots(layer); len(got) != 3 {
		t.Fatalf("slots = %v, want the duplicate engineer dropped and both placeholders kept", got)
	}
	if got := layerPeople(layer); len(got) != 1 || got[0] != "a@x.com" {
		t.Fatalf("people = %v, want only the engineer", got)
	}
}

// With OnCallAtOnce > 1 a placeholder leaves an unfilled seat: the group is one
// person short for that shift rather than pulling the next person in early.
func TestPlaceholderShrinksConcurrentGroup(t *testing.T) {
	layer := models.TeamLayer{
		People:             models.StringList{"a@x.com", "placeholder", "b@x.com"},
		RotateEverySeconds: week,
		OnCallAtOnce:       2,
		ActiveMode:         "always",
		StartsAt:           monday00(),
	}
	want := [][]string{
		{"a@x.com"},            // a + placeholder
		{"b@x.com", "a@x.com"}, // wraps around the list
		{"b@x.com"},            // placeholder + b
	}
	for i, w := range want {
		got := layerOnCallAt(layer, monday00()+int64(i)*week+3600)
		if !emailsEqual(got, w) {
			t.Fatalf("week %d: got %v want %v", i, got, w)
		}
	}
}

// The point of placeholders: swapping a person out for one leaves everybody
// else's shifts exactly where they were, which deleting them would not.
func TestPlaceholderKeepsRotationPhase(t *testing.T) {
	old := baseLayer() // weekly a/b/c anchored at monday00
	now := monday00() + 3*week + 3*24*3600
	got := mustNormalize(t, LayerInput{
		ID: old.ID, Priority: 1,
		People:             []string{"a@x.com", "placeholder:TBD", "c@x.com"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
	}, []models.TeamLayer{old}, now)

	if nb, ob := nextBoundaryAfter(got, now), nextBoundaryAfter(old, now); nb != ob {
		t.Fatalf("handover moved: %d != %d", nb, ob)
	}
	// b's week is now unstaffed; a and c keep the exact weeks they had.
	for w := int64(4); w < 10; w++ {
		at := monday00() + w*week + 3600
		want := layerOnCallAt(old, at)
		if want[0] == "b@x.com" {
			if h := layerOnCallAt(got, at); len(h) != 0 {
				t.Fatalf("week %d: want no one on call, got %v", w, h)
			}
			continue
		}
		if h := layerOnCallAt(got, at); !emailsEqual(h, want) {
			t.Fatalf("week %d: got %v want %v", w, h, want)
		}
	}
}

// Several empty slots can share one layer, including identically named ones —
// they are told apart by position, so a no-op resave while an unstaffed shift is
// the one in progress leaves the whole future schedule untouched.
func TestSeveralPlaceholdersInOneLayer(t *testing.T) {
	old := models.TeamLayer{
		ID: 7, TeamID: 1, Priority: 1,
		People:             models.StringList{"a@x.com", "placeholder", "b@x.com", "placeholder"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	}
	want := [][]string{{"a@x.com"}, {}, {"b@x.com"}, {}}
	for i, w := range want {
		if got := layerOnCallAt(old, monday00()+int64(i)*week+3600); !emailsEqual(got, w) {
			t.Fatalf("week %d: got %v want %v", i, got, w)
		}
	}

	// Week 3 is the SECOND placeholder — the one that only position distinguishes.
	now := monday00() + 3*week + 3600
	got := mustNormalize(t, LayerInput{
		ID: old.ID, Priority: 1,
		People:             []string{"a@x.com", "placeholder", "b@x.com", "placeholder"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
	}, []models.TeamLayer{old}, now)

	for w := int64(4); w < 12; w++ {
		at := monday00() + w*week + 3600
		if a, b := layerOnCallAt(got, at), layerOnCallAt(old, at); !emailsEqual(a, b) {
			t.Fatalf("week %d moved on a no-op resave: %v != %v", w, a, b)
		}
	}
}

// next_up_email pins a slot by name, so it is rejected when the name is shared —
// but naming a uniquely labelled empty slot stays legal (see
// TestPlaceholderAsNextUp).
func TestNextUpRejectsAmbiguousPlaceholder(t *testing.T) {
	_, err := normalizeTeamLayers(1, []LayerInput{{
		Priority: 1, People: []string{"a@x.com", "placeholder", "b@x.com", "placeholder"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
		NextUpEmail: "placeholder",
	}}, nil, monday00())
	if err == nil {
		t.Fatal("want an error for a next_up_email matching two slots")
	}
	if !strings.Contains(err.Error(), "matches 2 slots") {
		t.Fatalf("error should say which slot is ambiguous, got: %v", err)
	}
	// The same layer saves fine once the slots are told apart.
	if _, err := normalizeTeamLayers(1, []LayerInput{{
		Priority: 1, People: []string{"a@x.com", "placeholder:1", "b@x.com", "placeholder:2"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
		NextUpEmail: "placeholder:2",
	}}, nil, monday00()); err != nil {
		t.Fatalf("labelled slots should be pinnable: %v", err)
	}
}

// A layer must still have someone in it; placeholders alone are not a rotation.
func TestPlaceholderOnlyLayerRejected(t *testing.T) {
	_, err := normalizeTeamLayers(1, []LayerInput{{
		Priority: 1, People: []string{"placeholder", "placeholder:TBD"},
		RotateEverySeconds: week, ActiveMode: "always",
	}}, nil, monday00())
	if err == nil {
		t.Fatal("want error for a layer with no engineer")
	}
}

// next_up_email may name a placeholder, parking the upcoming shift on the gap.
func TestPlaceholderAsNextUp(t *testing.T) {
	old := baseLayer()
	now := monday00() + 3*week + 3*24*3600
	got := mustNormalize(t, LayerInput{
		ID: old.ID, Priority: 1,
		People:             []string{"a@x.com", "b@x.com", "placeholder:holiday"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
		NextUpEmail: "placeholder:holiday",
	}, []models.TeamLayer{old}, now)

	handoff := nextBoundaryAfter(old, now)
	if h := layerOnCallAt(got, handoff+3600); len(h) != 0 {
		t.Fatalf("want no one on call at the handoff, got %v", h)
	}
	if h := layerOnCallAt(got, handoff+week+3600); len(h) != 1 || h[0] != "a@x.com" {
		t.Fatalf("after the gap = %v, want a@x.com", h)
	}
}

// A placeholder shift is a genuine no-oncall gap: an override layer still seals
// the layers below it, and only combine_mode "append" lets them fill in.
func TestPlaceholderGapSealsLowerLayersUnlessAppend(t *testing.T) {
	top := models.TeamLayer{
		Priority: 2, People: models.StringList{"a@x.com", "placeholder"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
		CombineMode: combineOverride, StartsAt: monday00(),
	}
	bottom := models.TeamLayer{
		Priority: 1, People: models.StringList{"b@x.com"},
		OnCallAtOnce: 1, ActiveMode: "always", StartsAt: monday00(),
	}
	gap := monday00() + week + 3600

	if got := resolveLayersAt([]models.TeamLayer{top, bottom}, gap); got != nil {
		t.Fatalf("override: got %v, want no one on call", got)
	}
	top.CombineMode = combineAppend
	if got := resolveLayersAt([]models.TeamLayer{top, bottom}, gap); len(got) != 1 || got[0] != "b@x.com" {
		t.Fatalf("append: got %v, want b@x.com", got)
	}
}

// A placeholder round-trips through save -> read: next_up_email reads back as
// the token itself (so the client can send it straight back), with
// next_up_no_oncall flagging that nobody covers the upcoming shift.
func TestPlaceholderRoundTripsThroughLayersAPI(t *testing.T) {
	newTestDB(t)
	team := models.Team{Name: "t", DisplayName: "t"}
	db.DB.Create(&team)
	db.DB.Create(&models.EngineerTeam{EngineerID: mkEngineer(t, "Alice", "a@x.com"), TeamID: team.ID})

	s := &ScheduleService{}
	out, err := s.ReplaceTeamLayers(team.ID, []LayerInput{{
		Priority: 1, People: []string{"a@x.com", "placeholder:holiday"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
		NextUpEmail: "placeholder:holiday",
	}})
	if err != nil {
		t.Fatalf("save: %v", err)
	}
	items := out["layers"].([]map[string]interface{})
	if len(items) != 1 {
		t.Fatalf("want 1 layer, got %d", len(items))
	}
	if people := items[0]["people"].([]string); len(people) != 2 || people[1] != "placeholder:holiday" {
		t.Fatalf("people = %v, want the placeholder stored verbatim", people)
	}
	if got := items[0]["next_up_email"]; got != "placeholder:holiday" {
		t.Fatalf("next_up_email = %v, want the placeholder token", got)
	}
	if got := items[0]["next_up_no_oncall"]; got != true {
		t.Fatalf("next_up_no_oncall = %v, want true", got)
	}
}

// The materialized schedule simply omits the placeholder's shift.
func TestComputeWindowSkipsPlaceholderShift(t *testing.T) {
	layer := models.TeamLayer{
		Priority: 1, People: models.StringList{"a@x.com", "placeholder"},
		RotateEverySeconds: week, OnCallAtOnce: 1, ActiveMode: "always",
		StartsAt: monday00(),
	}
	segs := computeWindowFromLayers(1, []models.TeamLayer{layer}, monday00(), monday00()+4*week, nil)
	if len(segs) != 2 {
		t.Fatalf("want 2 segments (weeks 0 and 2), got %d: %+v", len(segs), segs)
	}
	if segs[0].StartTime != monday00() || segs[0].EndTime != monday00()+week {
		t.Fatalf("seg0 = %+v", segs[0])
	}
	if segs[1].StartTime != monday00()+2*week || segs[1].EndTime != monday00()+3*week {
		t.Fatalf("seg1 = %+v", segs[1])
	}
}
