package api

import (
	"encoding/json"
	"net/http"
	"sort"
	"strings"
	"time"

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

type adminOnboardingRating struct {
	ItemID string `json:"itemId"`
	Title  string `json:"title"`
	Rating int    `json:"rating"`
}

type adminOnboardingPreferences struct {
	Completed    bool                    `json:"completed"`
	Prompted     bool                    `json:"prompted"`
	Updated      bool                    `json:"updated"`
	Ratings      []adminOnboardingRating `json:"ratings"`
	Genres       []string                `json:"genres"`
	Studios      []string                `json:"studios"`
	Actors       []string                `json:"actors"`
	Actresses    []string                `json:"actresses"`
	Directors    []string                `json:"directors"`
	ContentTypes []string                `json:"contentTypes"`
}

type adminMembyAccount struct {
	ID              string                     `json:"id"`
	Username        string                     `json:"username"`
	CreatedAt       time.Time                  `json:"createdAt"`
	LastSeen        time.Time                  `json:"lastSeen"`
	Devices         []store.MembyDevice        `json:"devices"`
	Recommendations adminOnboardingPreferences `json:"recommendations"`
	// Settings is the same document the television reads, normalised the same way, so
	// the console is editing what the TV will actually receive rather than a projection
	// of it. Saved is false for someone who has never synced — the values shown are then
	// the defaults, and saying so is the difference between "chose this" and "has not
	// chosen anything".
	Settings adminAccountSettings `json:"settings"`
	// Themes is the ids this person may choose between, and an empty array means every
	// selectable theme rather than none — the same permissive reading the store and
	// themeAllowed take. The console renders that as every box ticked, which is what an
	// operator who has never touched the page should see.
	Themes []string `json:"themes"`
}

type adminAccountSettings struct {
	Saved       bool           `json:"saved"`
	Revision    int64          `json:"revision"`
	Source      string         `json:"source,omitempty"`
	UpdatedAt   any            `json:"updatedAt,omitempty"`
	Preferences map[string]any `json:"preferences"`
}

func (s *Server) handleAdminAccounts(w http.ResponseWriter, r *http.Request) {
	accounts, err := s.store.MembyAccounts(r.Context())
	if err != nil {
		s.loggerFor(r.Context()).Error("Memby account list failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not load Memby accounts")
		return
	}

	allRatingIDs := []string{}
	preferences := make(map[string]recommend.OnboardingPreferences, len(accounts))
	for _, account := range accounts {
		var pref recommend.OnboardingPreferences
		_ = json.Unmarshal(account.RecommendationPreferences, &pref)
		preferences[account.ID] = pref
		for id := range pref.Ratings {
			allRatingIDs = append(allRatingIDs, id)
		}
	}
	titles := map[string]string{}
	if raws, loadErr := s.store.LibraryItemsByID(r.Context(), allRatingIDs); loadErr == nil {
		for _, item := range recommend.Decode(raws) {
			titles[item.ID] = item.Name
		}
	}

	settings, err := s.store.AllUserPreferences(r.Context())
	if err != nil {
		// A settings read failure must not cost the operator the account list; the
		// devices and the sign-out buttons are what this page is for.
		s.loggerFor(r.Context()).Warn("account settings read failed", "error", err)
		settings = map[string]store.UserPreferences{}
	}

	themes, err := s.store.AllUserThemes(r.Context())
	if err != nil {
		// Same trade the settings read above makes: a colour allowlist that would not load
		// must not cost the operator the device list and the sign-out buttons.
		s.loggerFor(r.Context()).Warn("theme allowlist read failed", "error", err)
		themes = map[string][]string{}
	}

	result := make([]adminMembyAccount, 0, len(accounts))
	for _, account := range accounts {
		pref := preferences[account.ID]
		ratings := make([]adminOnboardingRating, 0, len(pref.Ratings))
		for itemID, rating := range pref.Ratings {
			title := titles[itemID]
			if title == "" {
				title = itemID
			}
			ratings = append(ratings, adminOnboardingRating{ItemID: itemID, Title: title, Rating: rating})
		}
		sort.Slice(ratings, func(i, j int) bool {
			if ratings[i].Rating != ratings[j].Rating {
				return ratings[i].Rating > ratings[j].Rating
			}
			return strings.ToLower(ratings[i].Title) < strings.ToLower(ratings[j].Title)
		})
		stored, saved := settings[account.ID]
		accountSettings := adminAccountSettings{
			Saved: saved, Revision: stored.Revision, Source: stored.Source,
			Preferences: decodePreferences(stored.Preferences),
		}
		if !stored.UpdatedAt.IsZero() {
			accountSettings.UpdatedAt = stored.UpdatedAt
		}
		result = append(result, adminMembyAccount{
			ID: account.ID, Username: account.Username, CreatedAt: account.CreatedAt,
			LastSeen: account.LastSeen, Devices: account.Devices, Settings: accountSettings,
			Themes: nonNilStrings(themes[account.ID]),
			Recommendations: adminOnboardingPreferences{
				Completed: pref.Completed, Prompted: pref.Prompted,
				Updated: len(account.RecommendationPreferences) > 2,
				Ratings: ratings, Genres: nonNilStrings(pref.Genres),
				Studios: nonNilStrings(pref.Studios), Actors: nonNilStrings(pref.Actors),
				Actresses: nonNilStrings(pref.Actresses),
				Directors: nonNilStrings(pref.Directors), ContentTypes: nonNilStrings(pref.ContentTypes),
			},
		})
	}
	// The catalogue rides along so the console builds its editor from the server's own
	// vocabulary. A page that hard-coded the controls would drift from what the TV
	// accepts the first time a setting is added, and would do it silently.
	writeJSON(w, http.StatusOK, map[string]any{
		"accounts": result, "catalogue": preferenceCatalogue,
		"schemaVersion": preferenceSchemaVersion,
		// The selectable themes, for the same reason the preference catalogue rides along:
		// a page that hard-coded the swatches would drift from what the gateway will accept
		// the first time a theme is added, and would do it without saying so. Seasonal ones
		// are absent because they are not grantable — see themes.go.
		"themes": selectableThemes(),
	})
}

func (s *Server) handleAdminPromptRecommendations(w http.ResponseWriter, r *http.Request) {
	userID := strings.TrimSpace(r.PathValue("userID"))
	if userID == "" {
		writeError(w, http.StatusBadRequest, "user is required")
		return
	}
	accounts, err := s.store.MembyAccounts(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not load Memby account")
		return
	}
	found := false
	for _, account := range accounts {
		if account.ID == userID {
			found = true
			break
		}
	}
	if !found {
		writeError(w, http.StatusNotFound, "Memby account not found")
		return
	}
	var preferences recommend.OnboardingPreferences
	if raw, err := s.store.RecommendationOnboarding(r.Context(), userID); err == nil {
		_ = json.Unmarshal(raw, &preferences)
	} else {
		writeError(w, http.StatusInternalServerError, "could not load recommendation choices")
		return
	}
	if preferences.Completed {
		writeError(w, http.StatusConflict, "recommendation setup is already complete")
		return
	}
	preferences.Prompted = true
	raw, _ := json.Marshal(preferences)
	if err := s.store.SetRecommendationOnboarding(r.Context(), userID, raw); err != nil {
		writeError(w, http.StatusInternalServerError, "could not queue recommendation prompt")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleAdminRenameDevice(w http.ResponseWriter, r *http.Request) {
	userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID"))
	var req renameDeviceRequest
	if userID == "" || deviceID == "" || json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<10)).Decode(&req) != nil {
		writeError(w, http.StatusBadRequest, "user, device and name are required")
		return
	}
	req.DeviceName = strings.TrimSpace(req.DeviceName)
	if req.DeviceName == "" || len([]rune(req.DeviceName)) > 80 {
		writeError(w, http.StatusBadRequest, "device name must be between 1 and 80 characters")
		return
	}
	if err := s.store.RenameUserDevice(r.Context(), userID, deviceID, req.DeviceName); err == store.ErrNotFound {
		writeError(w, http.StatusNotFound, "Memby device not found")
		return
	} else if err != nil {
		writeError(w, http.StatusInternalServerError, "could not rename Memby device")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleAdminDeleteDevice(w http.ResponseWriter, r *http.Request) {
	userID, deviceID := strings.TrimSpace(r.PathValue("userID")), strings.TrimSpace(r.PathValue("deviceID"))
	if userID == "" || deviceID == "" {
		writeError(w, http.StatusBadRequest, "user and device are required")
		return
	}
	hash, err := s.store.DeleteUserDevice(r.Context(), userID, deviceID)
	if err == store.ErrNotFound {
		writeError(w, http.StatusNotFound, "Memby device not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not sign out Memby device")
		return
	}
	if s.cache != nil {
		_ = s.cache.Delete(r.Context(), cache.SessionKey(hexHash(hash)))
	}
	s.retireEmbyDevice(r.Context(), deviceID)
	if err := s.store.DeleteDeviceVersions(r.Context(), deviceID); err != nil {
		s.loggerFor(r.Context()).Warn("device version cleanup failed",
			"removed_device_id", deviceID, "error", err)
	}
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) handleAdminDeleteAccount(w http.ResponseWriter, r *http.Request) {
	userID := strings.TrimSpace(r.PathValue("userID"))
	if userID == "" {
		writeError(w, http.StatusBadRequest, "user is required")
		return
	}
	hashes, err := s.store.DeleteUserSessions(r.Context(), userID)
	if err == store.ErrNotFound {
		writeError(w, http.StatusNotFound, "Memby account not found")
		return
	}
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not revoke Memby account access")
		return
	}
	if s.cache != nil {
		keys := make([]string, 0, len(hashes))
		for _, hash := range hashes {
			keys = append(keys, cache.SessionKey(hexHash(hash)))
		}
		_ = s.cache.Delete(r.Context(), keys...)
		_ = s.cache.InvalidateUser(r.Context(), userID)
	}
	w.WriteHeader(http.StatusNoContent)
}

type adminPreferencesRequest struct {
	Preferences map[string]any `json:"preferences"`
}

// handleAdminPushPreferences is the operator writing someone's TV settings for them.
//
// It writes with store.ForceRevision on purpose. Every one of that person's televisions
// is also a writer of this row, and making the console lose a revision race would mean an
// operator's deliberate change being quietly reverted by whichever set happened to sync
// next. The push wins; the televisions notice on their next status poll and adopt it.
func (s *Server) handleAdminPushPreferences(w http.ResponseWriter, r *http.Request) {
	userID := strings.TrimSpace(r.PathValue("userID"))
	if userID == "" {
		writeError(w, http.StatusBadRequest, "user is required")
		return
	}
	var req adminPreferencesRequest
	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(), userID, store.PreferenceWrite{
		Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin",
	})
	if err != nil {
		s.loggerFor(r.Context()).Error("admin preferences push failed", "user", userID, "error", err)
		writeError(w, http.StatusInternalServerError, "could not push those settings")
		return
	}
	s.loggerFor(r.Context()).Info("settings pushed to viewer",
		"user", userID, "revision", stored.Revision, "source", "admin")
	writeJSON(w, http.StatusOK, preferencesPayload(stored))
}

// handleAdminResetPreferences puts someone back on the defaults. It is a write rather than
// a delete so it still bumps the revision — a delete would leave every television holding
// the old document with nothing to tell them it had gone.
func (s *Server) handleAdminResetPreferences(w http.ResponseWriter, r *http.Request) {
	userID := strings.TrimSpace(r.PathValue("userID"))
	if userID == "" {
		writeError(w, http.StatusBadRequest, "user is required")
		return
	}
	raw, err := json.Marshal(normalizePreferences(nil))
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not build default settings")
		return
	}
	stored, err := s.store.SetUserPreferences(r.Context(), userID, store.PreferenceWrite{
		Preferences: raw, ExpectedRevision: store.ForceRevision, Source: "admin",
	})
	if err != nil {
		s.loggerFor(r.Context()).Error("admin preferences reset failed", "user", userID, "error", err)
		writeError(w, http.StatusInternalServerError, "could not reset those settings")
		return
	}
	s.loggerFor(r.Context()).Info("settings reset to defaults",
		"user", userID, "revision", stored.Revision, "source", "admin")
	writeJSON(w, http.StatusOK, preferencesPayload(stored))
}

func (s *Server) handleAdminResetRecommendations(w http.ResponseWriter, r *http.Request) {
	userID := strings.TrimSpace(r.PathValue("userID"))
	if userID == "" {
		writeError(w, http.StatusBadRequest, "user is required")
		return
	}
	if err := s.store.ClearRecommendationOnboarding(r.Context(), userID); err != nil {
		writeError(w, http.StatusInternalServerError, "could not reset recommendation choices")
		return
	}
	if s.cache != nil {
		_ = s.cache.Delete(r.Context(), cache.RecommendationsKey(userID))
	}
	w.WriteHeader(http.StatusNoContent)
}
