package api

import (
	"context"
	"crypto/subtle"
	_ "embed"
	"encoding/json"
	"net/http"
	"os"
	"runtime"
	"runtime/debug"
	"strconv"
	"strings"
	"time"

	"github.com/ponzischeme89/memby/server/internal/appupdate"
	"github.com/ponzischeme89/memby/server/internal/buildinfo"
	"github.com/ponzischeme89/memby/server/internal/library"
	"github.com/ponzischeme89/memby/server/internal/store"
)

const adminCookieName = "memby_admin"

// adminActivityHeader is how the console distinguishes its own poll from a request an
// operator caused. It is advice, not authority: a page that sets it always already holds
// a valid session, so the only thing it can extend is its own sign-in.
const adminActivityHeader = "X-Memby-Admin-Active"

// adminRoutes is the operator interface: library imports, the maintenance switch, and
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
// left exposed by accident.
func (s *Server) adminRoutes() http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /admin/{$}", s.handleAdminRoot)
	mux.HandleFunc("GET /admin/{page}", s.handleAdminPage)
	// One person's own page. It is a path rather than a query string so it can be linked,
	// bookmarked and returned to after a sign-in, like every other page here.
	mux.HandleFunc("GET /admin/accounts/{userID}", s.handleAdminAccountPage)
	// The settings history is its own page rather than a fifth card on the account: it is
	// a table with a row per change and an action per row, and it is read when something
	// has gone wrong rather than as part of ordinary account admin.
	mux.HandleFunc("GET /admin/accounts/{userID}/settings", s.handleAdminSettingsHistoryPage)
	mux.Handle("GET /admin/api/status", s.adminAuth(s.handleAdminStatus))
	mux.Handle("GET /admin/api/accounts", s.adminAuth(s.handleAdminAccounts))
	mux.Handle("PUT /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminRenameDevice))
	mux.Handle("DELETE /admin/api/accounts/{userID}/devices/{deviceID}", s.adminAuth(s.handleAdminDeleteDevice))
	mux.Handle("DELETE /admin/api/accounts/{userID}/sessions", s.adminAuth(s.handleAdminDeleteAccount))
	mux.Handle("PUT /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminPushPreferences))
	mux.Handle("DELETE /admin/api/accounts/{userID}/preferences", s.adminAuth(s.handleAdminResetPreferences))
	mux.Handle("GET /admin/api/accounts/{userID}/preferences/history", s.adminAuth(s.handleAdminPreferenceHistory))
	mux.Handle("POST /admin/api/accounts/{userID}/preferences/revisions/{revision}/restore",
		s.adminAuth(s.handleAdminRestorePreferences))
	mux.Handle("DELETE /admin/api/accounts/{userID}/recommendations", s.adminAuth(s.handleAdminResetRecommendations))
	mux.Handle("PUT /admin/api/accounts/{userID}/recommendations/prompt", s.adminAuth(s.handleAdminPromptRecommendations))
	mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
	mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
	mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
	mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
	mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
	mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
	mux.Handle("POST /admin/api/sync", s.adminAuth(s.handleAdminSync))
	mux.Handle("POST /admin/api/for-you", s.adminAuth(s.handleAdminForYou))
	mux.Handle("POST /admin/api/maintenance", s.adminAuth(s.handleAdminMaintenance))
	mux.Handle("POST /admin/api/deployment-alert", s.adminAuth(s.handleAdminDeploymentAlert))
	mux.Handle("POST /admin/api/update-policy", s.adminAuth(s.handleAdminUpdatePolicy))
	mux.Handle("POST /admin/api/request-policy", s.adminAuth(s.handleAdminRequestPolicy))
	mux.Handle("POST /admin/api/playback-policy", s.adminAuth(s.handleAdminPlaybackPolicy))
	mux.Handle("POST /admin/api/mdblist-settings", s.adminAuth(s.handleAdminMDBListSettings))
	mux.Handle("POST /admin/api/subtitle-settings", s.adminAuth(s.handleAdminSubtitleSettings))
	mux.Handle("POST /admin/api/subtitle-test", s.adminAuth(s.handleAdminSubtitleTest))
	mux.Handle("POST /admin/api/features", s.adminAuth(s.handleAdminFeaturePolicy))
	mux.Handle("POST /admin/api/release", s.releasePublishAuth(s.handleReleasePublish))

	return mux
}

func (s *Server) handleAdminRoot(w http.ResponseWriter, r *http.Request) {
	if s.cfg.AdminToken == "" {
		http.NotFound(w, r)
		return
	}
	http.Redirect(w, r, "/admin/overview", http.StatusFound)
}

type adminRuntimeStatus struct {
	Goroutines    int    `json:"goroutines"`
	GOMAXPROCS    int    `json:"gomaxprocs"`
	HeapAlloc     uint64 `json:"heapAlloc"`
	HeapInuse     uint64 `json:"heapInuse"`
	HeapIdle      uint64 `json:"heapIdle"`
	HeapReleased  uint64 `json:"heapReleased"`
	StackInuse    uint64 `json:"stackInuse"`
	Sys           uint64 `json:"sys"`
	NextGC        uint64 `json:"nextGc"`
	NumGC         uint32 `json:"numGc"`
	MemoryLimit   int64  `json:"memoryLimit"`
	ConfiguredLim string `json:"configuredLimit,omitempty"`
}

func (s *Server) handleAdminRuntime(w http.ResponseWriter, _ *http.Request) {
	var memory runtime.MemStats
	runtime.ReadMemStats(&memory)
	w.Header().Set("Cache-Control", "no-store")
	writeJSON(w, http.StatusOK, adminRuntimeStatus{
		Goroutines: runtime.NumGoroutine(), GOMAXPROCS: runtime.GOMAXPROCS(0),
		HeapAlloc: memory.HeapAlloc, HeapInuse: memory.HeapInuse,
		HeapIdle: memory.HeapIdle, HeapReleased: memory.HeapReleased,
		StackInuse: memory.StackInuse, Sys: memory.Sys,
		NextGC: memory.NextGC, NumGC: memory.NumGC,
		MemoryLimit: debug.SetMemoryLimit(-1), ConfiguredLim: os.Getenv("GOMEMLIMIT"),
	})
}

func (s *Server) handleAdminEvents(w http.ResponseWriter, r *http.Request) {
	if s.events == nil {
		writeJSON(w, http.StatusOK, map[string]any{
			"events": []any{}, "next": 0, "oldest": 0, "latest": 0,
			"dropped": 0, "hasMore": false,
		})
		return
	}
	after, _ := strconv.ParseInt(r.URL.Query().Get("after"), 10, 64)
	limit := queryInt(r, "limit", 500, 1000)
	writeJSON(w, http.StatusOK, s.events.Events(after, limit))
}

// adminAuth guards the admin API with the shared token. Browser requests need both the
// admin cookie and a current Emby-verified browser session. Automation can continue to
// send the admin token as a Bearer header without pretending to be a browser.
func (s *Server) adminAuth(h http.HandlerFunc) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if s.cfg.AdminToken == "" {
			http.NotFound(w, r)
			return
		}
		authorization := strings.TrimSpace(r.Header.Get("Authorization"))
		presented := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
		browser := presented == ""
		if browser {
			if cookie, err := r.Cookie(adminCookieName); err == nil {
				presented = cookie.Value
			}
		}
		if subtle.ConstantTimeCompare([]byte(presented), []byte(s.cfg.AdminToken)) != 1 ||
			(browser && !s.validInstallerSession(r)) {
			writeError(w, http.StatusUnauthorized, "invalid admin token")
			return
		}
		if browser && operatorPresent(r) {
			s.renewInstallerSession(w, r)
		}
		h(w, r)
	})
}

// operatorPresent reports whether a request came from somebody at the keyboard rather
// than from the console's own status poll. Only these extend the sign-in: the dashboard
// refreshes itself on a timer, so renewing on any request at all would leave a tab
// abandoned on a second monitor signed in forever. A mutation is a click by definition;
// for reads the page says so itself, setting the header only while there has been recent
// interaction with it.
func operatorPresent(r *http.Request) bool {
	return r.Method != http.MethodGet ||
		strings.TrimSpace(r.Header.Get(adminActivityHeader)) == "1"
}

func (s *Server) handleAdminPage(w http.ResponseWriter, r *http.Request) {
	// A hidden page is reachable only through the route that knows how to address it: an
	// account page with nobody to be about is not a page.
	page := strings.TrimSpace(r.PathValue("page"))
	if !adminPages[page] {
		http.NotFound(w, r)
		return
	}
	s.serveAdminPage(w, r, page, "/admin/"+page)
}

// The account page is served from its own route because the identity is in the path. The
// sign-in returns to /admin/accounts rather than to this URL: cleanInstallerDestination only
// admits the pages it can name, and a person's id is not one of them.
func (s *Server) handleAdminAccountPage(w http.ResponseWriter, r *http.Request) {
	if strings.TrimSpace(r.PathValue("userID")) == "" {
		http.NotFound(w, r)
		return
	}
	s.serveAdminPage(w, r, "account", "/admin/accounts")
}

// Hidden for the same reason the account page is: a settings history with nobody to be
// about is not a page, so the identity is in the path and a sign-in returns to the account
// list rather than here.
func (s *Server) handleAdminSettingsHistoryPage(w http.ResponseWriter, r *http.Request) {
	if strings.TrimSpace(r.PathValue("userID")) == "" {
		http.NotFound(w, r)
		return
	}
	s.serveAdminPage(w, r, "settings-history", "/admin/accounts")
}

func (s *Server) serveAdminPage(w http.ResponseWriter, r *http.Request, page, next string) {
	if s.cfg.AdminToken == "" {
		http.NotFound(w, r)
		return
	}
	body, ok := adminRendered[page]
	if !ok {
		http.NotFound(w, r)
		return
	}
	if !s.validInstallerSession(r) {
		s.renderAccessLogin(w, r, "", http.StatusOK, next)
		return
	}
	// Opening a page is somebody at the keyboard, so it starts the clock again.
	s.renewInstallerSession(w, r)
	secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
	http.SetCookie(w, &http.Cookie{
		Name:     adminCookieName,
		Value:    s.cfg.AdminToken,
		Path:     "/admin",
		MaxAge:   10 * 365 * 24 * 60 * 60,
		HttpOnly: true,
		Secure:   secure,
		SameSite: http.SameSiteStrictMode,
	})
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store")
	preventDiscovery(w)
	_, _ = w.Write(body)
}

type adminStatus struct {
	// ServerVersion is what the page's footer reports. An operator reading the live log
	// needs to know which build wrote it, and the page is the one place that is asked.
	ServerVersion  string                `json:"serverVersion"`
	Maintenance    store.Maintenance     `json:"maintenance"`
	UpdatePolicy   appupdate.Policy      `json:"updatePolicy"`
	Library        store.LibraryStats    `json:"library"`
	SyncRunning    bool                  `json:"syncRunning"`
	Runs           []store.SyncRun       `json:"runs"`
	SyncEvery      string                `json:"syncEvery"`
	ForYou         store.ForYouStats     `json:"forYou"`
	ForYouRunning  bool                  `json:"forYouRunning"`
	RequestPolicy  store.RequestPolicy   `json:"requestPolicy"`
	PlaybackPolicy store.PlaybackPolicy  `json:"playbackPolicy"`
	MDBList        mdblistAdminSettings  `json:"mdblist"`
	Subtitles      subtitleAdminSettings `json:"subtitles"`
	Features       featureResponse       `json:"features"`
	RequestUsers   []store.KnownUser     `json:"requestUsers"`
	Clients        []store.KnownClient   `json:"clients"`
	SonarrReady    bool                  `json:"sonarrReady"`
	RadarrReady    bool                  `json:"radarrReady"`
}

func (s *Server) handleAdminStatus(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	stats, err := s.store.LibraryStats(ctx)
	if err != nil {
		s.loggerFor(ctx).Error("library stats failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not read library stats")
		return
	}
	runs, err := s.store.RecentSyncRuns(ctx, 10)
	if err != nil {
		s.loggerFor(ctx).Error("sync history failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not read sync history")
		return
	}

	var forYouStats store.ForYouStats
	forYouRunning := false
	if s.forYou != nil {
		forYouStats, err = s.forYou.Stats(ctx)
		if err != nil {
			s.loggerFor(ctx).Warn("For You stats failed", "error", err)
		}
		forYouRunning = s.forYou.Running()
	}
	requestPolicy, err := s.store.RequestPolicy(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("request policy read failed", "error", err)
	}
	requestUsers, err := s.store.KnownUsers(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("known users read failed", "error", err)
	}
	clients, err := s.store.KnownClients(ctx)
	if err != nil {
		s.loggerFor(ctx).Warn("known clients read failed", "error", err)
	}
	writeJSON(w, http.StatusOK, adminStatus{
		ServerVersion: buildinfo.Version(),
		Maintenance:   s.maintenance.get(),
		UpdatePolicy:  s.updatePolicy.get(),
		Library:       stats,
		SyncRunning:   s.syncer.Running(),
		Runs:          runs,
		SyncEvery:     s.cfg.SyncInterval.String(),
		ForYou:        forYouStats,
		ForYouRunning: forYouRunning,
		RequestPolicy: requestPolicy,
		PlaybackPolicy: func() store.PlaybackPolicy {
			policy, policyErr := s.store.PlaybackPolicy(ctx)
			if policyErr != nil {
				s.loggerFor(ctx).Warn("playback policy read failed", "error", policyErr)
				return store.DefaultPlaybackPolicy()
			}
			return policy
		}(),
		Subtitles:    s.subtitleAdminSettings(ctx),
		Features:     featurePayload(s.currentFeaturePolicy(ctx), ProtocolVersion),
		RequestUsers: requestUsers,
		Clients:      clients,
		MDBList: func() mdblistAdminSettings {
			settings, settingsErr := s.store.MDBListSettings(ctx)
			if settingsErr != nil {
				s.loggerFor(ctx).Warn("MDBList settings read failed", "error", settingsErr)
				settings = store.DefaultMDBListSettings()
			}
			public := publicMDBListSettings(settings)
			// How much of the catalogue is already stored is the only way to see whether
			// the integration is still spending the operator's daily allowance.
			if total, stale, statsErr := s.store.MediaRatingsStats(
				ctx, time.Now().Add(-ratingsRefreshInterval),
			); statsErr == nil {
				public.CachedTitles, public.StaleTitles = total, stale
			}
			return public
		}(),
		SonarrReady: s.sonarr != nil,
		RadarrReady: s.radarr != nil,
	})
}

type mdblistAdminSettings struct {
	Enabled          bool     `json:"enabled"`
	APIKeyConfigured bool     `json:"apiKeyConfigured"`
	Sources          []string `json:"sources"`
	AvailableSources []string `json:"availableSources"`
	CachedTitles     int      `json:"cachedTitles"`
	StaleTitles      int      `json:"staleTitles"`
}

type mdblistSettingsRequest struct {
	Enabled     bool     `json:"enabled"`
	APIKey      string   `json:"apiKey"`
	ClearAPIKey bool     `json:"clearApiKey"`
	Sources     []string `json:"sources"`
}

func publicMDBListSettings(settings store.MDBListSettings) mdblistAdminSettings {
	return mdblistAdminSettings{
		Enabled: settings.Enabled, APIKeyConfigured: settings.APIKey != "",
		Sources: settings.Sources, AvailableSources: store.MDBListSources(),
	}
}

func (s *Server) handleAdminMDBListSettings(w http.ResponseWriter, r *http.Request) {
	var req mdblistSettingsRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	current, err := s.store.MDBListSettings(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not read MDBList settings")
		return
	}
	apiKey := current.APIKey
	if req.ClearAPIKey {
		apiKey = ""
	} else if replacement := strings.TrimSpace(req.APIKey); replacement != "" {
		apiKey = replacement
	}
	seen := map[string]bool{}
	sources := make([]string, 0, len(req.Sources))
	for _, source := range req.Sources {
		source = strings.ToLower(strings.TrimSpace(source))
		if source == "" || seen[source] {
			continue
		}
		if !store.ValidMDBListSource(source) {
			writeError(w, http.StatusBadRequest, "unknown MDBList rating source")
			return
		}
		seen[source] = true
		sources = append(sources, source)
	}
	if req.Enabled && apiKey == "" {
		writeError(w, http.StatusBadRequest, "set an MDBList API key before enabling ratings")
		return
	}
	if req.Enabled && len(sources) == 0 {
		writeError(w, http.StatusBadRequest, "select at least one MDBList rating source")
		return
	}
	if len(sources) == 0 {
		sources = current.Sources
	}
	next := store.MDBListSettings{Enabled: req.Enabled, APIKey: apiKey, Sources: sources}
	if err := s.store.SetMDBListSettings(r.Context(), next); err != nil {
		s.loggerFor(r.Context()).Error("MDBList settings write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not save MDBList settings")
		return
	}
	s.forgetMDBListSettings()
	stored, err := s.store.MDBListSettings(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not reload MDBList settings")
		return
	}
	s.loggerFor(r.Context()).Info("MDBList settings changed", "enabled", stored.Enabled, "sources", len(stored.Sources),
		"api_key_configured", stored.APIKey != "")
	writeJSON(w, http.StatusOK, publicMDBListSettings(stored))
}

type playbackPolicyRequest struct {
	PrerollEnabled    bool  `json:"prerollEnabled"`
	PrerollDurationMs int64 `json:"prerollDurationMs"`
}

func (s *Server) handleAdminPlaybackPolicy(w http.ResponseWriter, r *http.Request) {
	var req playbackPolicyRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	if req.PrerollDurationMs < 1_000 || req.PrerollDurationMs > 30_000 {
		writeError(w, http.StatusBadRequest, "preroll duration must be between 1 and 30 seconds")
		return
	}
	policy := store.PlaybackPolicy{
		PrerollEnabled: req.PrerollEnabled, PrerollDurationMs: req.PrerollDurationMs,
	}
	if err := s.store.SetPlaybackPolicy(r.Context(), policy); err != nil {
		s.loggerFor(r.Context()).Error("playback policy write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not save playback policy")
		return
	}
	stored, err := s.store.PlaybackPolicy(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not reload playback policy")
		return
	}
	s.loggerFor(r.Context()).Info("playback policy changed", "preroll_enabled", stored.PrerollEnabled,
		"preroll_duration_ms", stored.PrerollDurationMs)
	writeJSON(w, http.StatusOK, stored)
}

type requestPolicyRequest struct {
	AllowedUserIDs []string `json:"allowedUserIds"`
}

func (s *Server) handleAdminRequestPolicy(w http.ResponseWriter, r *http.Request) {
	var req requestPolicyRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	known, err := s.store.KnownUsers(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "could not validate users")
		return
	}
	valid := make(map[string]bool, len(known))
	for _, user := range known {
		valid[user.ID] = true
	}
	seen := map[string]bool{}
	allowed := make([]string, 0, len(req.AllowedUserIDs))
	for _, id := range req.AllowedUserIDs {
		id = strings.TrimSpace(id)
		if id == "" || seen[id] {
			continue
		}
		if !valid[id] {
			writeError(w, http.StatusBadRequest, "unknown Emby user")
			return
		}
		seen[id] = true
		allowed = append(allowed, id)
	}
	policy := store.RequestPolicy{AllowedUserIDs: allowed}
	if err := s.store.SetRequestPolicy(r.Context(), policy); err != nil {
		s.loggerFor(r.Context()).Error("request policy write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not save request access")
		return
	}
	s.loggerFor(r.Context()).Info("media request access changed", "users", len(allowed))
	writeJSON(w, http.StatusOK, policy)
}

type updatePolicyRequest struct {
	Enabled       bool   `json:"enabled"`
	LatestVersion string `json:"latestVersion"`
	DownloadURL   string `json:"downloadUrl"`
	Notes         string `json:"notes"`
	// Required makes this release mandatory for everyone below it. The page offers a
	// toggle rather than exposing "minimum version" directly, because "force this
	// update" is the decision an operator actually wants to make.
	Required bool `json:"required"`
	// MinimumVersion is honoured when set explicitly, for staged rollouts where the
	// forced floor is older than the latest build.
	MinimumVersion string `json:"minimumVersion"`
}

func (s *Server) handleAdminUpdatePolicy(w http.ResponseWriter, r *http.Request) {
	var req updatePolicyRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}

	policy := appupdate.Policy{
		Enabled:        req.Enabled,
		LatestVersion:  strings.TrimSpace(req.LatestVersion),
		MinimumVersion: strings.TrimSpace(req.MinimumVersion),
		DownloadURL:    strings.TrimSpace(req.DownloadURL),
		Notes:          strings.TrimSpace(req.Notes),
	}
	current := s.updatePolicy.get()
	if policy.LatestVersion == current.LatestVersion && policy.DownloadURL == current.DownloadURL {
		// Changing "required" or release notes must not silently discard integrity
		// metadata added by the signed release publisher.
		policy.SHA256 = current.SHA256
		policy.SizeBytes = current.SizeBytes
	}
	if req.Required {
		// Forcing means "nobody below the current build", so the floor is the latest.
		policy.MinimumVersion = policy.LatestVersion
	} else if policy.MinimumVersion == policy.LatestVersion {
		// Un-ticking the box must actually release the floor.
		policy.MinimumVersion = ""
	}

	if policy.Enabled && policy.LatestVersion == "" {
		writeError(w, http.StatusBadRequest, "set the latest version before enabling update prompts")
		return
	}
	if policy.Enabled && policy.DownloadURL == "" {
		writeError(w, http.StatusBadRequest, "set the APK download URL before enabling update prompts")
		return
	}

	if err := s.store.SetUpdatePolicy(r.Context(), policy); err != nil {
		s.loggerFor(r.Context()).Error("update policy write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not save the update policy")
		return
	}
	if err := s.LoadUpdatePolicy(r.Context()); err != nil {
		s.loggerFor(r.Context()).Warn("update policy reload failed", "error", err)
	}

	s.loggerFor(r.Context()).Info("update policy changed",
		"enabled", policy.Enabled, "latest", policy.LatestVersion, "minimum", policy.MinimumVersion)
	writeJSON(w, http.StatusOK, s.updatePolicy.get())
}

type syncRequest struct {
	Kind string `json:"kind"`
}

// handleAdminSync starts an import in the background and returns immediately. A full
// import of a large library takes minutes; the page polls /admin/api/status for progress.
func (s *Server) handleAdminSync(w http.ResponseWriter, r *http.Request) {
	var req syncRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	if req.Kind != "full" && req.Kind != "incremental" {
		writeError(w, http.StatusBadRequest, `kind must be "full" or "incremental"`)
		return
	}
	if s.syncer.Running() {
		writeError(w, http.StatusConflict, "a sync is already running")
		return
	}

	go func() {
		ctx, cancel := context.WithTimeout(context.WithoutCancel(context.Background()), s.cfg.SyncTimeout)
		defer cancel()
		if _, err := s.syncer.Sync(ctx, req.Kind, "manual"); err != nil {
			// Detached from the request on purpose, so the identity in the request
			// context is gone by now; name the area explicitly instead.
			s.log.Error("manual sync failed",
				"component", "admin", "kind", req.Kind, "error", err)
		}
	}()

	writeJSON(w, http.StatusAccepted, map[string]string{"status": "started", "kind": req.Kind})
}

type forYouAdminRequest struct {
	Action string `json:"action"`
}

// handleAdminForYou provides the recovery controls needed for an idempotent backfill:
// import all Tracearr sessions again, or rebuild every active user's derived pool.
func (s *Server) handleAdminForYou(w http.ResponseWriter, r *http.Request) {
	if s.forYou == nil {
		writeError(w, http.StatusServiceUnavailable, "Tracearr is not configured")
		return
	}
	var req forYouAdminRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}
	switch req.Action {
	case "incremental-import", "full-import", "rebuild-all":
	default:
		writeError(w, http.StatusBadRequest,
			`action must be "incremental-import", "full-import", or "rebuild-all"`)
		return
	}
	if s.forYou.Running() {
		writeError(w, http.StatusConflict, "For You maintenance is already running")
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), s.cfg.SyncTimeout)
		defer cancel()
		if req.Action != "rebuild-all" {
			_, err := s.forYou.Import(ctx, req.Action == "full-import")
			if err != nil {
				s.log.Error("manual Tracearr import failed",
					"component", "admin", "action", req.Action, "error", err)
				return
			}
		}
		if err := s.forYou.RebuildAll(ctx, true); err != nil {
			s.log.Error("manual For You rebuild failed",
				"component", "admin", "action", req.Action, "error", err)
		}
	}()
	writeJSON(w, http.StatusAccepted, map[string]string{
		"status": "started", "action": req.Action,
	})
}

// handleAdminDeploymentAlert announces that this gateway is about to be replaced.
//
// It takes no body and says the same thing every time: the caller is `deploy-server.ps1`,
// which runs before the old container is stopped, and a deployment script has no business
// deciding what appears over somebody's film. It is deliberately outside the maintenance
// switch — a deployment is not maintenance mode, and the point is to say so while the app
// is still working.
func (s *Server) handleAdminDeploymentAlert(w http.ResponseWriter, r *http.Request) {
	if s.cache == nil {
		// Alerts live in Redis; with none there is nowhere to publish to, and the deploy
		// must be told rather than believing every TV was warned.
		writeError(w, http.StatusServiceUnavailable, "alerts are unavailable without Redis")
		return
	}
	s.AnnounceDeployment(r.Context())
	s.loggerFor(r.Context()).Warn("deployment announced to clients",
		"component", "admin", "window", deploymentAlertWindow.String())
	writeJSON(w, http.StatusAccepted, map[string]string{
		"status":  "announced",
		"title":   deploymentAlertTitle,
		"message": deploymentAlertMessage,
	})
}

type maintenanceRequest struct {
	Enabled bool   `json:"enabled"`
	Message string `json:"message"`
}

func (s *Server) handleAdminMaintenance(w http.ResponseWriter, r *http.Request) {
	var req maintenanceRequest
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "malformed request body")
		return
	}

	state := store.Maintenance{Enabled: req.Enabled, Message: strings.TrimSpace(req.Message)}
	if err := s.store.SetMaintenance(r.Context(), state); err != nil {
		s.loggerFor(r.Context()).Error("maintenance write failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not update maintenance mode")
		return
	}
	if err := s.LoadMaintenance(r.Context()); err != nil {
		s.loggerFor(r.Context()).Warn("maintenance reload failed", "error", err)
	}

	s.loggerFor(r.Context()).Warn("maintenance mode changed", "enabled", state.Enabled, "message", state.Message)
	writeJSON(w, http.StatusOK, s.maintenance.get())
}

func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
	days := queryInt(r, "days", 7, 90)
	since := time.Now().UTC().AddDate(0, 0, -days)

	stats, err := s.store.RowStats(r.Context(), since)
	if err != nil {
		s.loggerFor(r.Context()).Error("row stats failed", "error", err)
		writeError(w, http.StatusInternalServerError, "could not read analytics")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"days": days, "rows": stats})
}

// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
// concrete type for testing.
type syncerHandle interface {
	Running() bool
	Sync(ctx context.Context, kind, trigger string) (library.Result, error)
}
