package api

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"slices"
	"strconv"
	"strings"

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

// The vocabulary of a viewer's TV settings, and the only place that decides what a legal
// value is.
//
// It is a catalogue rather than a struct for the same reason featureCatalogue is: the
// admin console renders its editor straight from this list, so a new setting is one entry
// here plus the matching key on the television — never a database migration and never a
// second copy of the rules in a web page. The client and the operator are then editing
// provably the same document, which is the whole point of moving settings off the TV.
//
// Anything that identifies a *television* rather than a person is deliberately absent:
// the device name, the update source, the screensaver's rotation and ring colour. Those
// belong to the box in the room and must not follow someone to another one.
const preferenceSchemaVersion = 1

type preferenceKind string

const (
	// A single on/off switch.
	preferenceToggle preferenceKind = "toggle"
	// Exactly one of Options.
	preferenceChoice preferenceKind = "choice"
	// An ordered subset of Options, never empty — the order is the setting.
	preferenceMulti preferenceKind = "multi"
	// An ordered list of free-form ids the server has no vocabulary for (server-composed
	// row ids, which change without an app release and so cannot be enumerated here).
	preferenceList preferenceKind = "list"
	// One of Numbers.
	preferenceNumber preferenceKind = "number"
)

type preferenceOption struct {
	Value string `json:"value"`
	Label string `json:"label"`
}

type preferenceDefinition struct {
	Key         string             `json:"key"`
	Name        string             `json:"name"`
	Description string             `json:"description"`
	Area        string             `json:"area"`
	Kind        preferenceKind     `json:"kind"`
	Options     []preferenceOption `json:"options,omitempty"`
	Numbers     []int              `json:"numbers,omitempty"`
	// Unit names what a number counts, for the admin console's editor. Without it every
	// number reads as minutes, which is what the first one to exist happened to be.
	Unit    string `json:"unit,omitempty"`
	Default any    `json:"default"`
}

func option(value, label string) preferenceOption {
	return preferenceOption{Value: value, Label: label}
}

var preferenceCatalogue = []preferenceDefinition{
	{
		Key: "homeSections", Name: "Home rows", Area: "Home",
		Description: "Which built-in rows the launcher shows, in order.",
		Kind:        preferenceMulti, Default: []string{"continue", "favorites", "latest"},
		Options: []preferenceOption{
			option("continue", "Continue watching"),
			option("favorites", "Favourites"),
			option("latest", "Latest movies"),
		},
	},
	{
		Key: "homeCardDensity", Name: "Card size", Area: "Home",
		Description: "How large the cards on browse rows are.",
		Kind:        preferenceChoice, Default: "standard",
		Options: []preferenceOption{
			option("compact", "Compact"), option("standard", "Standard"), option("large", "Large"),
		},
	},
	{
		Key: "homeArtworkStyle", Name: "Card artwork", Area: "Home",
		Description: "Poster or backdrop artwork on browse rows.",
		Kind:        preferenceChoice, Default: "automatic",
		Options: []preferenceOption{
			option("automatic", "Automatic"), option("poster", "Posters"), option("backdrop", "Backdrops"),
		},
	},
	{
		Key: "showHomeCardMetadata", Name: "Card metadata", Area: "Home",
		Description: "Show the year, runtime and badges beneath each card.",
		Kind:        preferenceToggle, Default: true,
	},
	{
		Key: "showRatingsStrip", Name: "Ratings", Area: "Home",
		Description: "Show third-party ratings on browse and detail pages.",
		Kind:        preferenceToggle, Default: true,
	},
	{
		Key: "hideWatchedMovies", Name: "Hide watched films", Area: "Home",
		Description: "Keep fully watched films out of browse rows and the hero.",
		Kind:        preferenceToggle, Default: false,
	},
	{
		Key: "showTitleLogo", Name: "Title logos", Area: "Presentation",
		Description: "Use each title's logo artwork in place of plain text.",
		Kind:        preferenceToggle, Default: true,
	},
	{
		// The one thing about themes a viewer decides. The options are the selectable
		// catalogue in themes.go rather than a list written out here, so a theme added
		// there cannot become a value this rejects.
		//
		// Note what this key does *not* control: whether a season is in force. That is
		// resolved server-side on top of this choice (resolveTheme), so a viewer's stored
		// selection survives underneath Halloween rather than being overwritten by it.
		// Note also that the per-user allowlist is not expressed here — this vocabulary is
		// the same for everybody, and an operator's restriction is applied at resolution.
		Key: "themeId", Name: "Colour scheme", Area: "Presentation",
		Description: "Which palette this viewer's televisions paint themselves.",
		Kind:        preferenceChoice, Default: defaultThemeID,
		Options: themeOptions(),
	},
	{
		Key: "welcomeQuoteStyle", Name: "Welcome tone", Area: "Presentation",
		Description: "Tone of the short line shown after signing in.",
		Kind:        preferenceChoice, Default: "neutral",
		Options: []preferenceOption{
			option("neutral", "Neutral"), option("positive", "Positive"), option("homicidal", "Homicidal"),
		},
	},
	{
		Key: "autoPlayNextEpisode", Name: "Auto-play next episode", Area: "Playback",
		Description: "Roll into the next episode when one finishes.",
		Kind:        preferenceToggle, Default: true,
	},
	{
		Key: "showTenMinuteReminder", Name: "Ten-minute reminder", Area: "Playback",
		Description: "Show the lower-third when ten minutes are left.",
		Kind:        preferenceToggle, Default: true,
	},
	{
		// The vocabulary is duplicated on the television (data/SkipIntroPreference.kt),
		// which normalises anything it does not recognise — so a mode added here reaches an
		// older set as "prompt" rather than as silence.
		Key: "skipIntroMode", Name: "Skip the title sequence", Area: "Playback",
		Description: "What to do when an episode reaches its opening titles.",
		Kind:        preferenceChoice, Default: skipIntroPrompt,
		Options: []preferenceOption{
			option(skipIntroPrompt, "Offer a button"),
			option(skipIntroAuto, "Skip automatically"),
			option(skipIntroOff, "Do nothing"),
		},
	},
	{
		// Default on: the whole feature is that it happens without being asked for, and it
		// is visible, reversible and over in a minute — a viewer who dislikes it turns it
		// off having seen exactly what it does. That is a different trade from
		// skipIntroMode's, which defaults to the button rather than the automatic seek
		// because a jump nobody can see coming is not recoverable by watching it.
		Key: "speedUpCredits", Name: "Speed through the credits", Area: "Playback",
		Description: "When an episode reaches its closing credits, shrink them to one side " +
			"at double speed and show what is on next beside them.",
		Kind: preferenceToggle, Default: true,
	},
	{
		Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
		Description: "Turn a subtitle track on automatically when the title has one.",
		Kind:        preferenceToggle, Default: true,
	},
	{
		Key: "subtitleLanguage", Name: "Subtitle language", Area: "Playback",
		Description: "Which language to choose when subtitles are turned on.",
		Kind:        preferenceChoice, Default: subtitleLanguageAuto,
		Options: subtitleLanguageOptions(),
	},
	{
		// The vocabulary is duplicated on the television (data/SeekPreference.kt), which
		// normalises anything it does not recognise — so this list may grow an interval
		// before every set in the house has the release that understands it.
		Key: "seekIntervalSeconds", Name: "Skip interval", Area: "Playback",
		Description: "How far one press of Left or Right moves playback, in seconds.",
		Kind:        preferenceNumber, Default: 10, Numbers: []int{10, 20, 30},
		Unit: "seconds",
	},
	{
		Key: "forYouMinutes", Name: "For You duration", Area: "Playback",
		Description: "Last time budget chosen in For You. 0 means no limit.",
		Kind:        preferenceNumber, Default: 0, Numbers: []int{0, 30, 60, 120},
		Unit: "minutes",
	},
	{
		Key: "homeRowOrder", Name: "Row order", Area: "Home layout",
		Description: "Server row ids in the order this viewer arranged them.",
		Kind:        preferenceList, Default: []string{},
	},
	{
		Key: "homePinnedRows", Name: "Pinned rows", Area: "Home layout",
		Description: "Server row ids kept at the top of the launcher.",
		Kind:        preferenceList, Default: []string{},
	},
	{
		Key: "homeHiddenRows", Name: "Hidden rows", Area: "Home layout",
		Description: "Server row ids this viewer has hidden.",
		Kind:        preferenceList, Default: []string{},
	},
}

// maxListEntries bounds the free-form id lists. They come from a television, and a row
// list long enough to matter is already a bug on that end.
const maxListEntries = 200

// maxListEntryLength bounds one id. Emby ids are short; a server row id is a slug.
const maxListEntryLength = 120

func preferenceDefinitionFor(key string) (preferenceDefinition, bool) {
	for _, definition := range preferenceCatalogue {
		if definition.Key == key {
			return definition, true
		}
	}
	return preferenceDefinition{}, false
}

// normalizePreferences returns a complete, legal document: every known key present, every
// unknown key dropped, every illegal value replaced by its default.
//
// Complete rather than sparse because both readers want it that way — a television
// applies the answer wholesale instead of merging, and the admin console renders an editor
// with nothing missing. Pure, and the one piece of this feature worth testing hard: it is
// what stands between a hand-edited admin request and a launcher that cannot draw a row.
func normalizePreferences(raw map[string]any) map[string]any {
	result := make(map[string]any, len(preferenceCatalogue))
	for _, definition := range preferenceCatalogue {
		result[definition.Key] = normalizePreference(definition, raw[definition.Key])
	}
	return result
}

func normalizePreference(definition preferenceDefinition, value any) any {
	switch definition.Kind {
	case preferenceToggle:
		if typed, ok := value.(bool); ok {
			return typed
		}
	case preferenceChoice:
		if typed, ok := value.(string); ok && hasOption(definition.Options, typed) {
			return typed
		}
	case preferenceMulti:
		if selected := stringList(value); len(selected) > 0 {
			kept := []string{}
			for _, entry := range selected {
				if hasOption(definition.Options, entry) && !slices.Contains(kept, entry) {
					kept = append(kept, entry)
				}
			}
			// An empty selection is a launcher with no rows at all, so the default
			// stands in rather than being honoured as a choice.
			if len(kept) > 0 {
				return kept
			}
		}
	case preferenceList:
		kept := []string{}
		for _, entry := range stringList(value) {
			entry = strings.TrimSpace(entry)
			// Newlines are how the television stores these; one inside an id would
			// come back as two rows.
			if entry == "" || len(entry) > maxListEntryLength ||
				strings.ContainsAny(entry, "\n\r") || slices.Contains(kept, entry) {
				continue
			}
			kept = append(kept, entry)
			if len(kept) == maxListEntries {
				break
			}
		}
		return kept
	case preferenceNumber:
		if number, ok := asInt(value); ok && slices.Contains(definition.Numbers, number) {
			return number
		}
	}
	return defaultValue(definition)
}

// defaultValue hands back a fresh copy of a slice default. Returning the catalogue's own
// slice would let a caller mutating the result edit the catalogue for the whole process.
func defaultValue(definition preferenceDefinition) any {
	if values, ok := definition.Default.([]string); ok {
		return append([]string{}, values...)
	}
	return definition.Default
}

func hasOption(options []preferenceOption, value string) bool {
	for _, candidate := range options {
		if candidate.Value == value {
			return true
		}
	}
	return false
}

func stringList(value any) []string {
	switch typed := value.(type) {
	case []string:
		return typed
	case []any:
		values := make([]string, 0, len(typed))
		for _, entry := range typed {
			if text, ok := entry.(string); ok {
				values = append(values, text)
			}
		}
		return values
	}
	return nil
}

// asInt accepts float64 because that is what encoding/json produces for every number.
func asInt(value any) (int, bool) {
	switch typed := value.(type) {
	case float64:
		return int(typed), typed == float64(int(typed))
	case int:
		return typed, true
	}
	return 0, false
}

func decodePreferences(raw json.RawMessage) map[string]any {
	values := map[string]any{}
	if len(raw) > 0 {
		_ = json.Unmarshal(raw, &values)
	}
	return normalizePreferences(values)
}

type preferencesResponse struct {
	SchemaVersion int                    `json:"schemaVersion"`
	Revision      int64                  `json:"revision"`
	UpdatedAt     any                    `json:"updatedAt,omitempty"`
	Source        string                 `json:"source,omitempty"`
	Preferences   map[string]any         `json:"preferences"`
	Catalogue     []preferenceDefinition `json:"catalogue"`
}

func preferencesPayload(stored store.UserPreferences) preferencesResponse {
	payload := preferencesResponse{
		SchemaVersion: preferenceSchemaVersion, Revision: stored.Revision,
		Source: stored.Source, Preferences: decodePreferences(stored.Preferences),
		Catalogue: preferenceCatalogue,
	}
	if !stored.UpdatedAt.IsZero() {
		payload.UpdatedAt = stored.UpdatedAt
	}
	return payload
}

type preferencesRequest struct {
	// Revision is the revision the television believes it is editing. A mismatch is a
	// 409 and never a retry: the loser re-reads, because the other writer is usually the
	// operator and silently overwriting them is exactly what this endpoint must not do.
	Revision    int64          `json:"revision"`
	Preferences map[string]any `json:"preferences"`
}

// handlePreferences is one handler for both verbs because they answer with the same
// document, and a client that has just written wants what was stored, not what it sent.
func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request, sess store.Session) {
	if r.Method == http.MethodGet {
		stored, err := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
		if err != nil {
			s.loggerFor(r.Context()).Error("preferences read failed", "error", err)
			writeError(w, http.StatusInternalServerError, "could not read your settings")
			return
		}
		s.recordPreferenceAck(r, sess, stored.Revision)
		writeJSON(w, http.StatusOK, preferencesPayload(stored))
		return
	}

	var req preferencesRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	raw, err := json.Marshal(normalizePreferences(req.Preferences))
	if err != nil {
		writeError(w, http.StatusBadRequest, "could not read those settings")
		return
	}
	stored, err := s.store.SetUserPreferences(r.Context(), sess.EmbyUserID, store.PreferenceWrite{
		Preferences: raw, ExpectedRevision: req.Revision, Source: "device",
		DeviceID: sess.DeviceID, DeviceName: sess.DeviceName, ClientVersion: sess.ClientVersion,
	})
	if err != nil {
		if errors.Is(err, store.ErrPreferencesConflict) {
			// The current document rides along with the 409 so the television can adopt
			// it without a second round trip — which matters, because the usual cause is
			// an operator push it has not caught up with yet.
			current, readErr := s.store.UserPreferences(r.Context(), sess.EmbyUserID)
			if readErr != nil {
				writeError(w, http.StatusConflict, "your settings changed elsewhere; reload them")
				return
			}
			// The television adopts what came back rather than retrying, so this set now
			// holds the winner's revision as surely as if it had fetched it.
			s.recordPreferenceAck(r, sess, current.Revision)
			writeJSON(w, http.StatusConflict, preferencesPayload(current))
			return
		}
		s.loggerFor(r.Context()).Error("preferences write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not save your settings")
		return
	}
	s.recordPreferenceAck(r, sess, stored.Revision)
	s.loggerFor(r.Context()).Info("preferences saved", "revision", stored.Revision, "source", "device")
	writeJSON(w, http.StatusOK, preferencesPayload(stored))
}

// recordPreferenceAck notes that this television now holds this revision.
//
// Best-effort on purpose, and never in front of the response: the viewer's settings have
// already been read or written correctly, and losing the operator's view of which sets are
// up to date is not worth failing that. A set whose ack is dropped records the next one.
func (s *Server) recordPreferenceAck(r *http.Request, sess store.Session, revision int64) {
	if s.store == nil {
		return
	}
	if err := s.store.RecordPreferenceAck(r.Context(), sess.EmbyUserID, sess.DeviceID,
		sess.DeviceName, sess.ClientVersion, revision); err != nil {
		s.loggerFor(r.Context()).Warn("preference acknowledgement not recorded", "error", err)
	}
}

// subtitlePreferenceFor reads this viewer's subtitle choice out of their synced settings,
// falling back to the catalogue defaults for anyone who has never expressed one.
//
// A read failure is not an error a viewer should ever see: it costs them the preferred
// language for one launch, and refusing to resolve playback over a settings row that would
// not load would be a much worse trade.
func (s *Server) subtitlePreferenceFor(ctx context.Context, sess store.Session) (bool, string) {
	enabled, _ := preferenceDefault("subtitlesEnabled").(bool)
	language, _ := preferenceDefault("subtitleLanguage").(string)
	if s.store == nil || sess.EmbyUserID == "" {
		return enabled, language
	}
	stored, err := s.store.UserPreferences(ctx, sess.EmbyUserID)
	if err != nil {
		s.loggerFor(ctx).Warn("subtitle preference unavailable", "error", err)
		return enabled, language
	}
	document := decodePreferences(stored.Preferences)
	if value, ok := document["subtitlesEnabled"].(bool); ok {
		enabled = value
	}
	if value, ok := document["subtitleLanguage"].(string); ok {
		language = value
	}
	return enabled, language
}

// preferenceChange is one setting that differs between two revisions, described in the
// words the catalogue uses rather than in the keys and raw values the document holds.
type preferenceChange struct {
	Key    string `json:"key"`
	Name   string `json:"name"`
	Area   string `json:"area"`
	Before string `json:"before"`
	After  string `json:"after"`
}

// preferenceChanges is what a history row actually says: "Card size: Standard → Large",
// not two JSON documents for the operator to compare by eye.
//
// Pure, and driven entirely by the catalogue, so a setting added tomorrow is described
// without touching this. It walks the catalogue rather than the documents on purpose —
// a key that has since been retired is not a change anybody can act on, and a value stored
// before a setting existed would otherwise read as one appearing out of nothing.
func preferenceChanges(before, after map[string]any) []preferenceChange {
	changes := []preferenceChange{}
	for _, definition := range preferenceCatalogue {
		was := normalizePreference(definition, before[definition.Key])
		now := normalizePreference(definition, after[definition.Key])
		if preferenceValueLabel(definition, was) == preferenceValueLabel(definition, now) {
			continue
		}
		changes = append(changes, preferenceChange{
			Key: definition.Key, Name: definition.Name, Area: definition.Area,
			Before: preferenceValueLabel(definition, was),
			After:  preferenceValueLabel(definition, now),
		})
	}
	return changes
}

// preferenceValueLabel is one stored value as a person reads it. Comparing labels rather
// than values is deliberate: two documents that render identically have not changed
// anything an operator could see, and a history row saying otherwise is noise.
func preferenceValueLabel(definition preferenceDefinition, value any) string {
	switch definition.Kind {
	case preferenceToggle:
		if enabled, _ := value.(bool); enabled {
			return "On"
		}
		return "Off"
	case preferenceChoice:
		selected, _ := value.(string)
		for _, option := range definition.Options {
			if option.Value == selected {
				return option.Label
			}
		}
		return selected
	case preferenceNumber:
		number, ok := asInt(value)
		if !ok {
			return ""
		}
		if number == 0 && definition.Unit != "" {
			return "No limit"
		}
		if definition.Unit != "" {
			return strconv.Itoa(number) + " " + definition.Unit
		}
		return strconv.Itoa(number)
	case preferenceMulti:
		labels := []string{}
		for _, entry := range stringList(value) {
			label := entry
			for _, option := range definition.Options {
				if option.Value == entry {
					label = option.Label
				}
			}
			labels = append(labels, label)
		}
		if len(labels) == 0 {
			return "None"
		}
		return strings.Join(labels, ", ")
	case preferenceList:
		entries := stringList(value)
		if len(entries) == 0 {
			return "None"
		}
		return strings.Join(entries, ", ")
	}
	return ""
}

func preferenceDefault(key string) any {
	definition, ok := preferenceDefinitionFor(key)
	if !ok {
		return nil
	}
	return defaultValue(definition)
}

// preferenceRevisionFor is the number /v1/status carries. A read failure reports 0 rather
// than an error: the poll's other job is maintenance, and losing that because a settings
// row could not be read would be a much worse trade.
func (s *Server) preferenceRevisionFor(r *http.Request, sess store.Session) int64 {
	if s.store == nil || sess.EmbyUserID == "" {
		return 0
	}
	revision, err := s.store.UserPreferenceRevision(r.Context(), sess.EmbyUserID)
	if err != nil {
		s.loggerFor(r.Context()).Warn("preference revision unavailable", "error", err)
		return 0
	}
	return revision
}
