package api

import (
	"slices"
	"testing"
	"time"
)

func date(year int, month time.Month, day int) time.Time {
	return time.Date(year, month, day, 12, 0, 0, 0, time.UTC)
}

// The windows are tested at both edges rather than in the middle, because the middle is
// never what breaks: a season that starts a day late is a season nobody sees the start of,
// and one that ends a day late is a red launcher on the 27th of December.
func TestSeasonalThemeWindows(t *testing.T) {
	cases := []struct {
		name string
		when time.Time
		want string
	}{
		{"the day before Halloween opens", date(2026, time.October, 24), ""},
		{"Halloween opens", date(2026, time.October, 25), themeHalloween},
		{"Halloween itself", date(2026, time.October, 31), themeHalloween},
		{"All Saints", date(2026, time.November, 1), themeHalloween},
		{"the day after Halloween closes", date(2026, time.November, 2), ""},

		{"the day before December", date(2026, time.November, 30), ""},
		{"December opens", date(2026, time.December, 1), themeChristmas},
		{"Boxing Day", date(2026, time.December, 26), themeChristmas},
		{"the day after Boxing Day", date(2026, time.December, 27), ""},
		{"New Year's Eve", date(2026, time.December, 31), ""},

		{"an ordinary day", date(2026, time.June, 14), ""},
	}
	for _, testCase := range cases {
		t.Run(testCase.name, func(t *testing.T) {
			if got := seasonalThemeFor(testCase.when); got != testCase.want {
				t.Fatalf("seasonalThemeFor(%s) = %q, want %q",
					testCase.when.Format(time.DateOnly), got, testCase.want)
			}
		})
	}
}

// Easter moves, which is the entire reason it is computed rather than listed. These are the
// real dates; if the computus is wrong the feature silently observes Easter on the wrong
// weekend, which nobody would report as a bug.
func TestEasterSunday(t *testing.T) {
	cases := map[int]string{
		2024: "2024-03-31", 2025: "2025-04-20", 2026: "2026-04-05",
		2027: "2027-03-28", 2030: "2030-04-21", 2038: "2038-04-25",
	}
	for year, want := range cases {
		if got := easterSunday(year).Format(time.DateOnly); got != want {
			t.Fatalf("easterSunday(%d) = %s, want %s", year, got, want)
		}
	}
}

func TestEasterWindowIsTheLongWeekend(t *testing.T) {
	// Easter Sunday 2026 is 5 April, so the window is Good Friday the 3rd to Easter
	// Monday the 6th.
	cases := []struct {
		day  int
		want string
	}{
		{2, ""}, {3, themeEaster}, {4, themeEaster}, {5, themeEaster}, {6, themeEaster}, {7, ""},
	}
	for _, testCase := range cases {
		if got := seasonalThemeFor(date(2026, time.April, testCase.day)); got != testCase.want {
			t.Fatalf("2026-04-%02d = %q, want %q", testCase.day, got, testCase.want)
		}
	}
}

// A season is the one thing on this feature nobody on a television can decline, so the
// tests that matter most are the ones asserting that no argument suppresses it.
func TestSeasonOutranksTheViewer(t *testing.T) {
	resolved := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
	if resolved.ID != themeChristmas {
		t.Fatalf("applied theme = %q, want %q", resolved.ID, themeChristmas)
	}
	if !resolved.Seasonal || !resolved.Locked {
		t.Fatalf("seasonal=%v locked=%v, want both true", resolved.Seasonal, resolved.Locked)
	}
	if resolved.Reason == "" {
		t.Fatal("a locked theme must say why; the picker has nothing else to print")
	}
	// The viewer's own choice survives underneath, or the picker would show nothing
	// selected for the length of the season and look as though it had been forgotten.
	if resolved.Chosen != themePlum {
		t.Fatalf("chosen = %q, want %q", resolved.Chosen, themePlum)
	}
}

// An allowlist narrows what somebody may pick. It must not narrow a season: those are not
// grantable per person, and an operator restricting a viewer to one palette must not be a
// way of exempting them from Christmas.
func TestAllowlistDoesNotApplyToSeasons(t *testing.T) {
	resolved := resolveTheme(themePlum, []string{themeEmber}, true, true, date(2026, time.October, 31))
	if resolved.ID != themeHalloween {
		t.Fatalf("applied theme = %q, want %q", resolved.ID, themeHalloween)
	}
	// The disallowed choice falls back to the default rather than being kept, so the
	// moment the season ends this set paints itself something the operator permits.
	if resolved.Chosen != defaultThemeID {
		t.Fatalf("chosen = %q, want the default %q", resolved.Chosen, defaultThemeID)
	}
}

func TestSeasonsOffLeavesTheViewersChoice(t *testing.T) {
	resolved := resolveTheme(themeEmber, nil, false, true, date(2026, time.December, 20))
	if resolved.ID != themeEmber {
		t.Fatalf("applied theme = %q, want %q", resolved.ID, themeEmber)
	}
	if resolved.Seasonal || resolved.Locked {
		t.Fatal("with seasonal themes off nothing is locked")
	}
}

func TestResolveThemeFallbacks(t *testing.T) {
	ordinary := date(2026, time.June, 14)
	cases := []struct {
		name    string
		chosen  string
		allowed []string
		want    string
	}{
		{"nothing chosen", "", nil, defaultThemeID},
		{"a theme this server does not have", "chartreuse", nil, defaultThemeID},
		{"a theme the operator withheld", themePlum, []string{themeEmber}, defaultThemeID},
		{"a theme the operator allowed", themePlum, []string{themePlum, themeEmber}, themePlum},
		{"no restriction at all", themeForest, []string{}, themeForest},
		// A season named directly is not a way in. It is not selectable, so it is not a
		// legal stored value however it got into the document.
		{"a season asked for out of season", themeHalloween, nil, defaultThemeID},
	}
	for _, testCase := range cases {
		t.Run(testCase.name, func(t *testing.T) {
			got := resolveTheme(testCase.chosen, testCase.allowed, true, true, ordinary)
			if got.ID != testCase.want {
				t.Fatalf("resolveTheme(%q, %v) = %q, want %q",
					testCase.chosen, testCase.allowed, got.ID, testCase.want)
			}
		})
	}
}

// The revision is the whole delivery mechanism: a television refetches the palette only when
// this moves. If it did not move when a season began, no set in the house would repaint.
func TestThemeRevisionTracksTheAnswer(t *testing.T) {
	ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
	christmas := resolveTheme(themePlum, nil, true, true, date(2026, time.December, 20))
	if ordinary.Revision == christmas.Revision {
		t.Fatal("the revision must change when the season does, or nothing refetches")
	}
	// And it must be stable, or every poll would look like a change and every set would
	// fetch the palette six times a minute.
	again := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 15))
	if ordinary.Revision != again.Revision {
		t.Fatalf("the revision moved on an ordinary day: %s then %s", ordinary.Revision, again.Revision)
	}
}

// The decoration is the operator's second switch. It must be able to come off without
// taking the palette with it, and it must never appear on a theme somebody chose to look at
// every day of the year.
func TestDecorationsAreSeasonalAndSeparatelySwitchable(t *testing.T) {
	christmas := date(2026, time.December, 20)
	if got := resolveTheme(themePlum, nil, true, true, christmas); got.Decoration != decorationSnow {
		t.Fatalf("decoration = %q, want %q", got.Decoration, decorationSnow)
	}
	off := resolveTheme(themePlum, nil, true, false, christmas)
	if off.Decoration != "" {
		t.Fatalf("decorations off still returned %q", off.Decoration)
	}
	if off.ID != themeChristmas {
		t.Fatal("turning decorations off must keep the seasonal palette")
	}
	// The revision has to move, or a set already snowing is never told to stop.
	if off.Revision == resolveTheme(themePlum, nil, true, true, christmas).Revision {
		t.Fatal("the revision must change when the decoration does")
	}
	ordinary := resolveTheme(themePlum, nil, true, true, date(2026, time.June, 14))
	if ordinary.Decoration != "" {
		t.Fatalf("a chosen theme carries a decoration: %q", ordinary.Decoration)
	}
}

func TestOnlySeasonalThemesDeclareADecoration(t *testing.T) {
	for _, theme := range selectableThemes() {
		if theme.Decoration != "" {
			t.Fatalf("%q is selectable and must not decorate: %q", theme.ID, theme.Decoration)
		}
	}
	for _, theme := range themeCatalogue {
		if theme.Seasonal && theme.Decoration == "" {
			t.Fatalf("%q is a season with nothing to draw", theme.ID)
		}
	}
}

func TestSelectableThemesExcludeSeasons(t *testing.T) {
	for _, theme := range selectableThemes() {
		if theme.Seasonal {
			t.Fatalf("%q is seasonal and must never be offered as a choice", theme.ID)
		}
	}
	if !slices.Contains(selectableThemeIDs(), defaultThemeID) {
		t.Fatalf("the default theme %q must be selectable", defaultThemeID)
	}
}

// Every theme must set every token. A palette missing one is a theme that half-applies,
// which reads on a television as a rendering fault rather than as a colour scheme.
func TestEveryThemeSetsEveryColour(t *testing.T) {
	for _, theme := range themeCatalogue {
		palette := theme.Palette
		for name, value := range map[string]string{
			"surface": palette.Surface, "surfaceRaised": palette.SurfaceRaised,
			"accent": palette.Accent, "onSurface": palette.OnSurface,
			"mutedText": palette.MutedText, "quietText": palette.QuietText,
			"hairline": palette.Hairline, "ratingsSurface": palette.RatingsSurface,
		} {
			if len(value) != 9 || value[0] != '#' {
				t.Fatalf("%s.%s = %q, want #AARRGGBB", theme.ID, name, value)
			}
			for _, digit := range value[1:] {
				if !((digit >= '0' && digit <= '9') || (digit >= 'A' && digit <= 'F')) {
					t.Fatalf("%s.%s = %q is not upper-case hex", theme.ID, name, value)
				}
			}
		}
		if theme.Name == "" || theme.Description == "" {
			t.Fatalf("%s must carry a name and a line for the picker", theme.ID)
		}
	}
}

// The catalogue and the preference vocabulary have to agree, or a viewer can be offered a
// theme the settings write will reject — which reads as a picker that does not work.
func TestThemePreferenceOptionsMatchTheCatalogue(t *testing.T) {
	definition, ok := preferenceDefinitionFor("themeId")
	if !ok {
		t.Fatal("themeId is missing from the preference catalogue")
	}
	if len(definition.Options) != len(selectableThemes()) {
		t.Fatalf("themeId offers %d options for %d selectable themes",
			len(definition.Options), len(selectableThemes()))
	}
	for _, theme := range selectableThemes() {
		if !hasOption(definition.Options, theme.ID) {
			t.Fatalf("themeId does not offer %q", theme.ID)
		}
	}
	if normalizePreference(definition, themeHalloween) != defaultThemeID {
		t.Fatal("a seasonal id must not normalise to itself as a stored choice")
	}
}

func TestNormalizeThemeAllowlist(t *testing.T) {
	everything := normalizeThemeAllowlist(selectableThemeIDs())
	if len(everything) != 0 {
		// "Every box ticked" and "never configured" are the same decision, and storing
		// them differently is how the two would drift apart as themes are added.
		t.Fatalf("an unrestricted list must store as empty, got %v", everything)
	}
	kept := normalizeThemeAllowlist([]string{themeEmber, "chartreuse", themeHalloween, themeEmber})
	if len(kept) != 1 || kept[0] != themeEmber {
		t.Fatalf("normalizeThemeAllowlist dropped the wrong things: %v", kept)
	}
	// Catalogue order, not request order, so two operators ticking the same boxes in a
	// different sequence store the same row.
	ordered := normalizeThemeAllowlist([]string{themePlum, themeMidnight})
	if !slices.Equal(ordered, []string{themeMidnight, themePlum}) {
		t.Fatalf("allowlist is not in catalogue order: %v", ordered)
	}
}
