package api

import (
	"context"
	"encoding/json"
	"hash/fnv"
	"net/http"
	"slices"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/ponzischeme89/memby/server/internal/store"
)

// The colour a television paints itself, decided here rather than there.
//
// The whole feature is server-owned for the same reason the row composition and the
// subtitle choice are: a palette that shipped in the APK could only change with a release,
// and these sets are sideloaded one at a time. Deciding it here means an operator can hand
// a household a new scheme, restrict what a particular viewer may choose, and — the part
// that has to happen without anybody doing anything — put the whole house into a seasonal
// theme on the right morning and take it away again afterwards.
//
// Two kinds of theme, and the difference is the point:
//
//   - A **selectable** theme is the viewer's own choice, held as the ordinary synced
//     preference `themeId` and picked in Settings → Appearance from whatever the operator
//     has allowed them.
//   - A **seasonal** theme is not a choice at all. It is in force for its dates and nothing
//     on the television can decline it — there is no "off" in the picker, because a switch
//     for it is exactly what somebody would leave switched off in October and never think
//     about again. The one control that exists is the operator's, as a feature flag, and it
//     is all-or-nothing for the whole house.
//
// Both are palettes and nothing else. A theme changes colour; it never changes what a row
// contains, where a control sits, or whether a feature exists — so a theme this build has
// never heard of is at worst the wrong shade, never a launcher that will not draw.
const themeSchemaVersion = 1

// themePalette is the whole vocabulary a theme may set, and it is deliberately the exact
// token list in the television's ui/theme/DesignTokens.kt. A palette carrying a colour the
// TV has no slot for would be a promise the client cannot keep; a palette missing one is a
// theme that half-applies, which reads as a bug rather than as a design.
//
// Colours are "#RRGGBB" or "#AARRGGBB" — the alpha-first order Android writes, because that
// is the one end that has to parse them.
type themePalette struct {
	Surface        string `json:"surface"`
	SurfaceRaised  string `json:"surfaceRaised"`
	Accent         string `json:"accent"`
	OnSurface      string `json:"onSurface"`
	MutedText      string `json:"mutedText"`
	QuietText      string `json:"quietText"`
	Hairline       string `json:"hairline"`
	RatingsSurface string `json:"ratingsSurface"`
}

// The decorations a theme may ask a television to draw over its launcher. A slug rather
// than a description of the animation: the drawing lives on the TV, in Compose, and the
// gateway has no business describing shapes to it. A client that does not recognise one
// draws nothing, which is why this can gain a decoration before the fleet has the build
// that knows it — the MembyHeroLabel precedent.
const (
	decorationSnow    = "snow"
	decorationBats    = "bats"
	decorationBlossom = "blossom"
)

type themeDefinition struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	// Description is what the picker prints under the name. One short line: the viewer is
	// reading it from across a room and the swatch is doing most of the work.
	Description string `json:"description"`
	// Seasonal themes are never offered in the picker and never stored as anybody's choice.
	Seasonal bool         `json:"seasonal"`
	Palette  themePalette `json:"palette"`
	// Decoration is what drifts over the launcher while this theme is on. Only seasonal
	// themes carry one: a scheme somebody chose to look at every day of the year must not
	// have things falling across it, and a viewer who wanted that would have no way to stop
	// it. It is empty on every selectable theme by construction rather than by a check at
	// the point of use.
	Decoration string `json:"decoration,omitempty"`
}

const (
	themeMidnight  = "midnight"
	themeGraphite  = "graphite"
	themeMidnightB = "indigo"
	themeEmber     = "ember"
	themeForest    = "forest"
	themePlum      = "plum"

	themeHalloween = "halloween"
	themeChristmas = "christmas"
	themeEaster    = "easter"
)

// defaultThemeID is what a viewer who has never chosen gets, and what an illegal choice
// falls back to. It is the palette the app shipped with before themes existed, so nothing
// changes appearance on the day this lands.
const defaultThemeID = themeMidnight

// themeCatalogue is the only place a theme is declared: the picker, the admin console's
// allowlist editor and the set of legal `themeId` values all read it.
//
// The neutrals move with the accent rather than staying fixed. A single accent swapped into
// one grey shell reads as a stray coloured button rather than as a theme, and on a panel
// this dark a hairline that does not carry a hint of the accent disappears entirely.
var themeCatalogue = []themeDefinition{
	{
		ID: themeMidnight, Name: "Midnight", Description: "The Memby original — near-black and Emby green.",
		Palette: themePalette{
			Surface: "#FF090B0D", SurfaceRaised: "#FF101418", Accent: "#FF52B54B",
			OnSurface: "#FFE2E5E8", MutedText: "#FFD0D6DB", QuietText: "#FFAEB7BF",
			Hairline: "#28FFFFFF", RatingsSurface: "#FF20252A",
		},
	},
	{
		ID: themeGraphite, Name: "Graphite", Description: "Warm grey and amber, easier on a bright room.",
		Palette: themePalette{
			Surface: "#FF0D0C0A", SurfaceRaised: "#FF181614", Accent: "#FFE0A33C",
			OnSurface: "#FFE9E5DE", MutedText: "#FFD8D2C8", QuietText: "#FFB6AEA1",
			Hairline: "#28FFF3DC", RatingsSurface: "#FF262320",
		},
	},
	{
		ID: themeMidnightB, Name: "Indigo", Description: "Deep blue with a cool electric accent.",
		Palette: themePalette{
			Surface: "#FF07090F", SurfaceRaised: "#FF111726", Accent: "#FF5C8DFF",
			OnSurface: "#FFE1E6F0", MutedText: "#FFCBD4E4", QuietText: "#FFA5B0C6",
			Hairline: "#28C7D8FF", RatingsSurface: "#FF1D2435",
		},
	},
	{
		ID: themeEmber, Name: "Ember", Description: "Charcoal and a low red, for watching in the dark.",
		Palette: themePalette{
			Surface: "#FF0C0808", SurfaceRaised: "#FF181111", Accent: "#FFE05B4A",
			OnSurface: "#FFEDE3E1", MutedText: "#FFDACECB", QuietText: "#FFB8A7A3",
			Hairline: "#28FFD5CE", RatingsSurface: "#FF261B1A",
		},
	},
	{
		ID: themeForest, Name: "Forest", Description: "Muted green on a near-black that leans warm.",
		Palette: themePalette{
			Surface: "#FF080B09", SurfaceRaised: "#FF111713", Accent: "#FF7FC08A",
			OnSurface: "#FFE3E8E3", MutedText: "#FFCFD8CF", QuietText: "#FFA9B5AA",
			Hairline: "#28D2F0D6", RatingsSurface: "#FF1E2620",
		},
	},
	{
		ID: themePlum, Name: "Plum", Description: "Aubergine and soft violet.",
		Palette: themePalette{
			Surface: "#FF0B080D", SurfaceRaised: "#FF171020", Accent: "#FFB37FE0",
			OnSurface: "#FFE7E2EC", MutedText: "#FFD5CCDD", QuietText: "#FFB0A4BC",
			Hairline: "#28E4D2FF", RatingsSurface: "#FF241B2D",
		},
	},

	// --- Seasonal. Never offered, never stored, never declined. ------------------------
	{
		ID: themeHalloween, Name: "Halloween", Seasonal: true,
		Description: "Pumpkin orange on black, for the last week of October.",
		Decoration:  decorationBats,
		Palette: themePalette{
			Surface: "#FF0A0704", SurfaceRaised: "#FF17100A", Accent: "#FFFF8A1F",
			OnSurface: "#FFF2E7DA", MutedText: "#FFE2D2BE", QuietText: "#FFBBA48C",
			Hairline: "#28FFB870", RatingsSurface: "#FF26190E",
		},
	},
	{
		ID: themeChristmas, Name: "Christmas", Seasonal: true,
		Description: "Pine and holly red, through December.",
		Decoration:  decorationSnow,
		Palette: themePalette{
			Surface: "#FF060A07", SurfaceRaised: "#FF0E1710", Accent: "#FFE0403F",
			OnSurface: "#FFEAF0E9", MutedText: "#FFD6E0D5", QuietText: "#FFAEBCAE",
			Hairline: "#28CFE8CF", RatingsSurface: "#FF19261B",
		},
	},
	{
		ID: themeEaster, Name: "Easter", Seasonal: true,
		Description: "Pale spring colours over the Easter weekend.",
		Decoration:  decorationBlossom,
		Palette: themePalette{
			Surface: "#FF0A0910", SurfaceRaised: "#FF15131F", Accent: "#FF9BD3F0",
			OnSurface: "#FFEDE9F2", MutedText: "#FFDCD6E4", QuietText: "#FFB6AEC4",
			Hairline: "#28D8E9F7", RatingsSurface: "#FF211E2E",
		},
	},
}

func themeDefinitionFor(id string) (themeDefinition, bool) {
	for _, theme := range themeCatalogue {
		if theme.ID == id {
			return theme, true
		}
	}
	return themeDefinition{}, false
}

// selectableThemes is the catalogue a viewer could ever be offered, before the operator's
// per-user allowlist narrows it. Seasonal themes are absent by construction rather than
// filtered at the point of use, so there is no code path that can offer one as a choice.
func selectableThemes() []themeDefinition {
	themes := make([]themeDefinition, 0, len(themeCatalogue))
	for _, theme := range themeCatalogue {
		if !theme.Seasonal {
			themes = append(themes, theme)
		}
	}
	return themes
}

func selectableThemeIDs() []string {
	ids := make([]string, 0, len(themeCatalogue))
	for _, theme := range selectableThemes() {
		ids = append(ids, theme.ID)
	}
	return ids
}

// themeOptions renders the selectable catalogue as preference options, so the `themeId`
// entry in preferenceCatalogue cannot drift from the themes that actually exist.
//
// It lists every selectable theme rather than only the ones a given viewer may pick,
// because normalizePreferences is pure and per-viewer policy is not a vocabulary question.
// The allowlist is applied at resolution instead — see resolveTheme.
func themeOptions() []preferenceOption {
	options := make([]preferenceOption, 0, len(themeCatalogue))
	for _, theme := range selectableThemes() {
		options = append(options, option(theme.ID, theme.Name))
	}
	return options
}

// --- The seasons ------------------------------------------------------------------------

// themeSeason is one window in the calendar and the theme it puts the house into.
type themeSeason struct {
	theme string
	// contains answers for a local date. A function rather than a pair of dates because
	// Easter is not on one.
	contains func(year int, month time.Month, day int) bool
}

// seasons are checked in order and the first match wins, which only matters if two windows
// ever overlap. They do not today, and the ordering is what stops a future one silently
// producing two answers.
var seasons = []themeSeason{
	{
		// The last week of October and All Saints' Day. It starts a week out rather than on
		// the day: a theme nobody sees until the evening of the 31st is one nobody sees.
		theme: themeHalloween,
		contains: func(_ int, month time.Month, day int) bool {
			return (month == time.October && day >= 25) || (month == time.November && day == 1)
		},
	},
	{
		// December up to and including Boxing Day. It stops before New Year deliberately —
		// the tree is down, and a red-and-green launcher on the 30th reads as a server
		// nobody is maintaining.
		theme: themeChristmas,
		contains: func(_ int, month time.Month, day int) bool {
			return month == time.December && day <= 26
		},
	},
	{
		// Good Friday to Easter Monday, computed rather than listed: Easter moves, and a
		// hard-coded table is a feature with an expiry date on it.
		theme: themeEaster,
		contains: func(year int, month time.Month, day int) bool {
			sunday := easterSunday(year)
			date := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
			return !date.Before(sunday.AddDate(0, 0, -2)) && !date.After(sunday.AddDate(0, 0, 1))
		},
	},
}

// easterSunday is the anonymous Gregorian computus. It is arithmetic with no calendar
// library behind it and no table to go stale, which is the only reason Easter is affordable
// as a season at all.
func easterSunday(year int) time.Time {
	a := year % 19
	b := year / 100
	c := year % 100
	d := b / 4
	e := b % 4
	f := (b + 8) / 25
	g := (b - f + 1) / 3
	h := (19*a + b - d - g + 15) % 30
	i := c / 4
	k := c % 4
	l := (32 + 2*e + 2*i - h - k) % 7
	m := (a + 11*h + 22*l) / 451
	month := (h + l - 7*m + 114) / 31
	day := ((h + l - 7*m + 114) % 31) + 1
	return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}

// seasonalThemeFor is the theme in force on a given day, or "" for most of the year.
//
// Pure, and takes the time rather than reading the clock, so every window can be tested at
// both of its edges without waiting for October. The date is read in whatever location the
// caller hands it in: the gateway runs on the household's own machine, and "Christmas" means
// the calendar on the wall in that house, not a UTC instant.
func seasonalThemeFor(now time.Time) string {
	year, month, day := now.Date()
	for _, season := range seasons {
		if season.contains(year, month, day) {
			return season.theme
		}
	}
	return ""
}

// --- Resolution -------------------------------------------------------------------------

// resolvedTheme is the answer a television is given: one palette, and enough about where it
// came from for the picker to explain itself.
type resolvedTheme struct {
	ID      string       `json:"id"`
	Name    string       `json:"name"`
	Palette themePalette `json:"palette"`
	// Seasonal says this palette was not chosen by anybody.
	Seasonal bool `json:"seasonal"`
	// Locked is what the picker obeys: while it is true the viewer's own choice is still
	// stored and still shown, but it cannot be changed and is not what is on screen. It is
	// a separate field from Seasonal rather than the same one, because a future reason to
	// lock a theme (an operator pinning one, say) must not have to claim to be a season.
	Locked bool `json:"locked"`
	// Chosen is the viewer's own selection, still theirs underneath a season. Without it
	// the picker would have nothing to show as selected for the fortnight a season is up,
	// and would look as though the choice had been forgotten.
	Chosen string `json:"chosen"`
	// Decoration is what the launcher draws over itself: "snow", "bats", "blossom", or
	// empty for the whole rest of the year. Empty is also what an operator who has turned
	// the decorations off gets, which is why it is resolved here rather than read off the
	// theme by the television — a set holding a cached Christmas palette must not keep
	// snowing after the switch has been thrown.
	Decoration string `json:"decoration,omitempty"`
	// Reason is the sentence the picker prints while it is locked. The gateway's wording,
	// the MembyHeroLabel precedent, so a season invented later reads correctly on today's
	// build rather than as a blank space where an explanation should be.
	Reason string `json:"reason,omitempty"`
	// Revision changes whenever the bytes of this answer would change. The status poll
	// carries it and the television refetches only when it moves — which is what makes a
	// season arriving overnight cost one request rather than a palette on every poll.
	//
	// A string, not a number: it is a 64-bit hash, and JSON numbers are float64 in both
	// the admin console and anything else that reads this. It is only ever compared for
	// equality, so its being opaque costs nothing.
	Revision string `json:"revision"`
}

// resolveTheme is the whole rule, and it is pure.
//
// Order matters and is the feature: a season outranks the viewer, the viewer outranks the
// default, and the operator's allowlist is applied to the viewer's choice but never to a
// season. That last part is what "cannot be removed or controlled by the user" means in
// code — there is no argument to this function that a television could send which suppresses
// a season. The only switch is seasonalEnabled, and that is the operator's feature flag.
func resolveTheme(
	chosen string,
	allowed []string,
	seasonalEnabled bool,
	decorationsEnabled bool,
	now time.Time,
) resolvedTheme {
	// The viewer's own choice first, so it is reported even while a season covers it.
	pick, ok := themeDefinitionFor(chosen)
	if !ok || pick.Seasonal || !themeAllowed(pick.ID, allowed) {
		pick, _ = themeDefinitionFor(defaultThemeID)
	}

	applied, seasonal, reason := pick, false, ""
	if seasonalEnabled {
		if id := seasonalThemeFor(now); id != "" {
			if season, found := themeDefinitionFor(id); found {
				applied, seasonal = season, true
				reason = season.Name + " is on for everyone until it is over."
			}
		}
	}

	// Decorations are a second switch, not a consequence of the first. A household on a
	// weak box may well want the December palette and nothing moving over it, and a
	// decoration is by far the more expensive half — it is the only thing in the app that
	// animates continuously while somebody is browsing.
	decoration := ""
	if seasonal && decorationsEnabled {
		decoration = applied.Decoration
	}

	resolved := resolvedTheme{
		ID: applied.ID, Name: applied.Name, Palette: applied.Palette,
		Seasonal: seasonal, Locked: seasonal, Chosen: pick.ID, Reason: reason,
		Decoration: decoration,
	}
	resolved.Revision = themeRevision(resolved)
	return resolved
}

// themeAllowed applies the operator's per-user list. An empty list is *permissive*: no row
// has ever been written for the great majority of households, and reading that as "this
// person may have no themes" would empty every picker in the house the day this ships.
func themeAllowed(id string, allowed []string) bool {
	if len(allowed) == 0 {
		return true
	}
	return slices.Contains(allowed, id)
}

// themeRevision is a hash of the answer rather than a counter in a table, because there is
// no write to attach a counter to: this changes when the calendar turns over or when a
// deployment edits the catalogue, and neither of those is a row anybody updates.
func themeRevision(resolved resolvedTheme) string {
	digest := fnv.New64a()
	palette := resolved.Palette
	for _, part := range []string{
		strconv.Itoa(themeSchemaVersion), resolved.ID, resolved.Chosen,
		strconv.FormatBool(resolved.Seasonal), strconv.FormatBool(resolved.Locked), resolved.Reason,
		resolved.Decoration,
		palette.Surface, palette.SurfaceRaised, palette.Accent, palette.OnSurface,
		palette.MutedText, palette.QuietText, palette.Hairline, palette.RatingsSurface,
	} {
		_, _ = digest.Write([]byte(part))
		_, _ = digest.Write([]byte{0})
	}
	return strconv.FormatUint(digest.Sum64(), 10)
}

// --- Serving ------------------------------------------------------------------------------

// themeFor resolves this viewer's theme from the three things it depends on: their stored
// choice, the operator's allowlist for them, and the clock.
//
// Every read failure degrades to the default rather than to an error. A launcher that will
// not open because a colour could not be looked up would be an absurd trade, and the
// palette it falls back to is the one the app shipped with.
func (s *Server) themeFor(ctx context.Context, sess store.Session) resolvedTheme {
	chosen, _ := preferenceDefault("themeId").(string)
	allowed := []string(nil)
	if s.store != nil && sess.EmbyUserID != "" {
		if stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID); err == nil {
			if value, ok := decodePreferences(stored.Preferences)["themeId"].(string); ok {
				chosen = value
			}
		} else {
			s.loggerFor(ctx).Warn("theme preference unavailable", "error", err)
		}
		if list, err := s.store.UserThemes(ctx, sess.EmbyUserID); err == nil {
			allowed = list
		} else {
			s.loggerFor(ctx).Warn("theme allowlist unavailable", "error", err)
		}
	}
	return resolveTheme(
		chosen, allowed,
		s.featureEnabled(ctx, featureSeasonalThemes),
		s.featureEnabled(ctx, featureSeasonalDecorations),
		s.now(),
	)
}

// now is the gateway's own clock, in its own location. Seasons are calendar dates in the
// house the server sits in; see seasonalThemeFor.
func (s *Server) now() time.Time { return time.Now() }

// themeStatus is the summary /v1/status carries: enough for a television to know whether
// what it is painted with is still right, and nothing more.
func themeStatus(resolved resolvedTheme) map[string]any {
	return map[string]any{
		"id":       resolved.ID,
		"revision": resolved.Revision,
		"seasonal": resolved.Seasonal,
		"locked":   resolved.Locked,
	}
}

type themeResponse struct {
	SchemaVersion int               `json:"schemaVersion"`
	Theme         resolvedTheme     `json:"theme"`
	Available     []themeDefinition `json:"available"`
}

// handleTheme is the full document, fetched only when the revision on the status poll moves.
//
// It carries the *available* list as well as the applied palette, so the television's picker
// is drawn from the server's answer for this particular viewer rather than from a catalogue
// compiled into the APK. That is what makes the per-user allowlist real: a theme an operator
// has withheld is not a greyed-out row on the TV, it is a row that was never sent.
func (s *Server) handleTheme(w http.ResponseWriter, r *http.Request, sess store.Session) {
	resolved := s.themeFor(r.Context(), sess)
	allowed := []string(nil)
	if s.store != nil && sess.EmbyUserID != "" {
		if list, err := s.store.UserThemes(r.Context(), sess.EmbyUserID); err == nil {
			allowed = list
		}
	}
	available := []themeDefinition{}
	for _, theme := range selectableThemes() {
		if themeAllowed(theme.ID, allowed) {
			available = append(available, theme)
		}
	}
	// A viewer whose allowlist has been emptied down to nothing legal still gets the
	// default, or Settings → Appearance is a page with no rows on it and no way back to one.
	if len(available) == 0 {
		if fallback, ok := themeDefinitionFor(defaultThemeID); ok {
			available = append(available, fallback)
		}
	}
	writeJSON(w, http.StatusOK, themeResponse{
		SchemaVersion: themeSchemaVersion, Theme: resolved, Available: available,
	})
}

// --- The operator's allowlist ---------------------------------------------------------------

// normalizeThemeAllowlist is what stands between a hand-edited admin request and a viewer
// with a picker full of themes that do not exist. Unknown and seasonal ids are dropped —
// a season is not a thing that can be granted or withheld per person — and the result is
// ordered by the catalogue so two operators saving the same set store the same row.
//
// A list that selects everything selectable is stored as nothing at all, which keeps the
// permissive default meaning one thing: "the operator has not restricted this person".
func normalizeThemeAllowlist(ids []string) []string {
	kept := []string{}
	for _, theme := range selectableThemes() {
		if slices.Contains(ids, theme.ID) {
			kept = append(kept, theme.ID)
		}
	}
	if len(kept) == len(selectableThemes()) {
		return []string{}
	}
	return kept
}

type adminThemesRequest struct {
	Themes []string `json:"themes"`
}

func (s *Server) handleAdminUserThemes(w http.ResponseWriter, r *http.Request) {
	userID := strings.TrimSpace(r.PathValue("userID"))
	if userID == "" {
		writeError(w, http.StatusBadRequest, "user is required")
		return
	}
	var req adminThemesRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	allowed := normalizeThemeAllowlist(req.Themes)
	if err := s.store.SetUserThemes(r.Context(), userID, allowed); err != nil {
		s.loggerFor(r.Context()).Error("theme allowlist write failed", "user", userID, "error", err)
		writeError(w, http.StatusInternalServerError, "could not save those themes")
		return
	}
	s.loggerFor(r.Context()).Info("themes allowed for viewer",
		"user", userID, "themes", themeListLabel(allowed))
	writeJSON(w, http.StatusOK, map[string]any{"themes": allowed})
}

// themeListLabel is for the log line, where "all" says more than an empty array does.
func themeListLabel(allowed []string) string {
	if len(allowed) == 0 {
		return "all"
	}
	sorted := append([]string{}, allowed...)
	sort.Strings(sorted)
	return strings.Join(sorted, ",")
}
