package server

import (
	"bytes"
	"encoding/csv"
	"errors"
	"fmt"
	"html/template"
	"net/http"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/google/uuid"
	"go.uber.org/zap"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/adminconsole"
	"scrabble/backend/internal/dictadmin"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/payments"
	"scrabble/backend/internal/ratewatch"
	"scrabble/backend/internal/robot"
	"scrabble/backend/internal/social"
)

// adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50

// registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard
// on the state-changing POSTs (docs/ARCHITECTURE.md §12). The console reads the
// account, game and dictionary surfaces, so it mounts only when those are wired.
func (s *Server) registerConsole(router *gin.Engine) {
	if s.accounts == nil || s.games == nil || s.registry == nil {
		return
	}
	s.console = adminconsole.MustNewRenderer()
	assets, err := adminconsole.Assets()
	if err != nil {
		panic(err)
	}

	gm := router.Group("/_gm")
	gm.Use(requireSameOrigin())
	gm.StaticFS("/assets", http.FS(assets))
	gm.GET("/", s.consoleDashboard)
	gm.GET("/users", s.consoleUsers)
	gm.GET("/users/:id", s.consoleUserDetail)
	gm.POST("/users/:id/message", s.consoleUserMessage)
	gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
	gm.POST("/users/:id/block", s.consoleBlockUser)
	gm.POST("/users/:id/unblock", s.consoleUnblockUser)
	gm.POST("/users/:id/grant-role", s.consoleGrantRole)
	gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
	gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
	gm.POST("/users/:id/grant", s.consoleGrant)
	gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
	gm.POST("/users/:id/refund", s.consoleRefund)
	gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
	gm.POST("/users/:id/delete", s.consoleDeleteUser)
	gm.GET("/reasons", s.consoleReasons)
	gm.POST("/reasons", s.consoleCreateReason)
	gm.POST("/reasons/:id/update", s.consoleUpdateReason)
	gm.POST("/reasons/:id/delete", s.consoleDeleteReason)
	gm.GET("/throttled", s.consoleThrottled)
	gm.POST("/bans/unban", s.consoleUnban)
	gm.GET("/games", s.consoleGames)
	gm.GET("/games/:id", s.consoleGameDetail)
	if s.gamelimits != nil {
		gm.GET("/limits", s.consoleLimits)
		gm.POST("/limits", s.consoleUpdateLimits)
	}
	gm.GET("/complaints", s.consoleComplaints)
	gm.GET("/complaints/:id", s.consoleComplaintDetail)
	gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
	if s.feedback != nil {
		gm.GET("/feedback", s.consoleFeedback)
		gm.GET("/feedback/:id", s.consoleFeedbackDetail)
		gm.GET("/feedback/:id/attachment", s.consoleFeedbackAttachment)
		gm.POST("/feedback/:id/read", s.consoleFeedbackRead)
		gm.POST("/feedback/:id/reply", s.consoleFeedbackReply)
		gm.POST("/feedback/:id/archive", s.consoleFeedbackArchive)
		gm.POST("/feedback/:id/delete", s.consoleFeedbackDelete)
		gm.POST("/feedback/:id/delete-all", s.consoleFeedbackDeleteAll)
	}
	gm.GET("/messages", s.consoleMessages)
	gm.GET("/messages.csv", s.consoleMessagesCSV)
	gm.GET("/messages/:id", s.consoleChatMessageDetail)
	gm.GET("/dictionary", s.consoleDictionary)
	gm.POST("/dictionary/upload", s.consoleUploadDictionary)
	gm.POST("/dictionary/install", s.consoleInstallDictionary)
	gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
	gm.GET("/broadcast", s.consoleBroadcast)
	gm.POST("/broadcast", s.consolePostBroadcast)
	if s.ads != nil {
		gm.GET("/banners", s.consoleBanners)
		gm.POST("/banners", s.consoleCreateBanner)
		gm.GET("/banners/:id", s.consoleBannerDetail)
		gm.POST("/banners/:id", s.consoleUpdateBanner)
		gm.POST("/banners/:id/delete", s.consoleDeleteBanner)
		gm.POST("/banners/:id/messages", s.consoleAddBannerMessage)
		gm.POST("/banners/:id/messages/:mid", s.consoleEditBannerMessage)
		gm.POST("/banners/:id/messages/:mid/delete", s.consoleDeleteBannerMessage)
		gm.POST("/banners/:id/messages/:mid/move", s.consoleMoveBannerMessage)
		gm.GET("/banner-settings", s.consoleBannerSettings)
		gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
	}
	if s.payments != nil {
		gm.GET("/catalog", s.consoleCatalog)
		gm.POST("/catalog", s.consoleCreateProduct)
		gm.POST("/catalog/reward", s.consoleSetReward)
		gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
		gm.GET("/catalog/:id", s.consoleCatalogDetail)
		gm.POST("/catalog/:id", s.consoleUpdateProduct)
		gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
		gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
		gm.GET("/ledger", s.consoleLedger)
		gm.GET("/ledger.csv", s.consoleLedgerExport)
	}
	// The professional-income tax export, registered only when an encryption key is configured —
	// without one there is nowhere safe to keep the tax cabinet's refresh token, so the whole rail
	// stays dormant rather than half-present.
	s.registerMyNalogConsole(gm)
}

// consoleDashboard renders the landing page: the top-line counts and the resident
// dictionary versions.
func (s *Server) consoleDashboard(c *gin.Context) {
	ctx := c.Request.Context()
	view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
	// The Users card counts people only: robots are pool infrastructure, not registered
	// users, so an empty filter (Robots: false) excludes them.
	view.Accounts, _ = s.accounts.CountUsers(ctx, account.UserFilter{})
	view.Games, _ = s.games.CountGames(ctx, "")
	view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
	view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
	if s.feedback != nil {
		view.OpenFeedback, _ = s.feedback.CountUnread(ctx)
	}
	if changes, err := s.games.DictionaryChanges(ctx); err == nil {
		view.PendingChanges = len(changes)
	}
	s.renderConsole(c, "dashboard", "dashboard", "Dashboard", view)
}

// consoleUsers renders the paginated account list.
func (s *Server) consoleUsers(c *gin.Context) {
	ctx := c.Request.Context()
	page := consolePage(c)
	filter := account.UserFilter{
		Robots:         c.Query("kind") == "robots",
		Deleted:        c.Query("kind") == "deleted",
		NameMask:       c.Query("name"),
		ExternalIDMask: c.Query("ext"),
		EmailExact:     c.Query("email"),
	}
	total, _ := s.accounts.CountUsers(ctx, filter)
	items, err := s.accounts.ListUsers(ctx, filter, adminPageSize, (page-1)*adminPageSize)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	q := url.Values{}
	if filter.Robots {
		q.Set("kind", "robots")
	}
	if filter.Deleted {
		q.Set("kind", "deleted")
	}
	if strings.TrimSpace(filter.NameMask) != "" {
		q.Set("name", filter.NameMask)
	}
	if strings.TrimSpace(filter.ExternalIDMask) != "" {
		q.Set("ext", filter.ExternalIDMask)
	}
	if strings.TrimSpace(filter.EmailExact) != "" {
		q.Set("email", filter.EmailExact)
	}
	view := adminconsole.UsersView{
		Pager:  adminconsole.NewPager(page, adminPageSize, total),
		Robots: filter.Robots, Deleted: filter.Deleted, NameMask: filter.NameMask, ExternalIDMask: filter.ExternalIDMask,
		EmailExact:  filter.EmailExact,
		FilterQuery: template.URL(q.Encode()),
	}
	ids := make([]uuid.UUID, 0, len(items))
	for _, it := range items {
		kind := "registered"
		switch {
		case it.IsRobot:
			kind = "robot"
		case it.IsDeleted:
			kind = "deleted"
		case it.IsGuest:
			kind = "guest"
		}
		view.Items = append(view.Items, adminconsole.UserRow{
			ID: it.ID.String(), DisplayName: it.DisplayName, Kind: kind,
			Language: it.PreferredLanguage, Guest: it.IsGuest, Deleted: it.IsDeleted,
			FlaggedHighRate: !it.FlaggedHighRateAt.IsZero(), CreatedAt: fmtTime(it.CreatedAt),
		})
		ids = append(ids, it.ID)
	}
	if stats, err := s.games.MoveDurationStats(ctx, ids); err == nil {
		for i := range view.Items {
			if st, ok := stats[ids[i]]; ok && st.Moves > 0 {
				view.Items[i].HasMoveStats = true
				view.Items[i].MoveMin = adminconsole.FormatDuration(st.MinSecs)
				view.Items[i].MoveAvg = adminconsole.FormatDuration(st.AvgSecs)
				view.Items[i].MoveMax = adminconsole.FormatDuration(st.MaxSecs)
			}
		}
	}
	s.renderConsole(c, "users", "users", "Users", view)
}

// consoleMessages renders the paginated chat-message moderation list, optionally pinned to
// one game (?game=) or sender (?user=) and filtered by sender glob masks (?name / ?ext).
func (s *Server) consoleMessages(c *gin.Context) {
	ctx := c.Request.Context()
	page := consolePage(c)
	gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
	userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
	filter := social.AdminMessageFilter{
		GameID:     gameID,
		SenderID:   userID,
		NameMask:   c.Query("name"),
		ExtMask:    c.Query("ext"),
		UnreadOnly: c.Query("unread") == "1",
	}
	total, _ := s.social.AdminCountMessages(ctx, filter)
	items, err := s.social.AdminListMessages(ctx, filter, adminPageSize, (page-1)*adminPageSize)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	q := url.Values{}
	if filter.GameID != uuid.Nil {
		q.Set("game", filter.GameID.String())
	}
	if filter.SenderID != uuid.Nil {
		q.Set("user", filter.SenderID.String())
	}
	if strings.TrimSpace(filter.NameMask) != "" {
		q.Set("name", filter.NameMask)
	}
	if strings.TrimSpace(filter.ExtMask) != "" {
		q.Set("ext", filter.ExtMask)
	}
	if filter.UnreadOnly {
		q.Set("unread", "1")
	}
	view := adminconsole.MessagesView{
		Pager:       adminconsole.NewPager(page, adminPageSize, total),
		NameMask:    filter.NameMask,
		ExtMask:     filter.ExtMask,
		UnreadOnly:  filter.UnreadOnly,
		FilterQuery: template.URL(q.Encode()),
	}
	if filter.GameID != uuid.Nil {
		view.GameID = filter.GameID.String()
	}
	if filter.SenderID != uuid.Nil {
		view.UserID = filter.SenderID.String()
	}
	for _, m := range items {
		view.Items = append(view.Items, adminconsole.MessageRow{
			ID: m.ID.String(), SenderID: m.SenderID.String(), SenderName: m.SenderName,
			Source: m.Source, IP: m.SenderIP, Body: m.Body,
			GameID: m.GameID.String(), CreatedAt: fmtTime(m.CreatedAt), Unread: m.UnreadSeats != 0,
		})
	}
	s.renderConsole(c, "messages", "messages", "Messages", view)
}

// consoleChatMessageDetail renders one chat message with the per-seat read breakdown of
// its game (who has read it, who has not).
func (s *Server) consoleChatMessageDetail(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/messages")
	if !ok {
		return
	}
	card, err := s.social.AdminMessageDetail(c.Request.Context(), id)
	if err != nil {
		if errors.Is(err, social.ErrMessageNotFound) {
			s.renderConsoleMessage(c, "Not found", "no such message", "/_gm/messages")
			return
		}
		s.consoleError(c, err)
		return
	}
	view := adminconsole.ChatMessageDetailView{
		ID: card.ID.String(), GameID: card.GameID.String(), SenderID: card.SenderID.String(),
		SenderName: card.SenderName, Source: card.Source, Kind: card.Kind, Body: card.Body,
		IP: card.SenderIP, CreatedAt: fmtTime(card.CreatedAt), Unread: card.UnreadSeats != 0,
	}
	for _, st := range card.Seats {
		view.Seats = append(view.Seats, adminconsole.ChatSeatStatusRow{
			Seat: st.Seat, AccountID: st.AccountID.String(), DisplayName: st.DisplayName, Role: st.Role,
		})
	}
	s.renderConsole(c, "chatmessage", "messages", "Message", view)
}

// adminMessagesExportCap bounds the CSV export row count (the moderated chat volume is small).
const adminMessagesExportCap = 100000

// consoleMessagesCSV exports the whole filtered chat-message list (ignoring pagination) as a
// CSV download, for offline moderation review.
func (s *Server) consoleMessagesCSV(c *gin.Context) {
	ctx := c.Request.Context()
	gameID, _ := uuid.Parse(strings.TrimSpace(c.Query("game")))
	userID, _ := uuid.Parse(strings.TrimSpace(c.Query("user")))
	filter := social.AdminMessageFilter{
		GameID:     gameID,
		SenderID:   userID,
		NameMask:   c.Query("name"),
		ExtMask:    c.Query("ext"),
		UnreadOnly: c.Query("unread") == "1",
	}
	items, err := s.social.AdminListMessages(ctx, filter, adminMessagesExportCap, 0)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	c.Header("Content-Type", "text/csv; charset=utf-8")
	c.Header("Content-Disposition", `attachment; filename="messages.csv"`)
	w := csv.NewWriter(c.Writer)
	_ = w.Write([]string{"time", "source", "sender_id", "sender", "ip", "message", "game_id", "read"})
	for _, m := range items {
		read := "read"
		if m.UnreadSeats != 0 {
			read = "unread"
		}
		// The sender name and message body are user-controlled; defuse spreadsheet formula
		// injection so a moderator opening the export can't trigger a formula.
		_ = w.Write([]string{
			fmtTime(m.CreatedAt), m.Source, m.SenderID.String(), csvSafe(m.SenderName), csvSafe(m.SenderIP), csvSafe(m.Body), m.GameID.String(), read,
		})
	}
	w.Flush()
}

// csvSafe defuses CSV/spreadsheet formula injection: a value a spreadsheet would treat as a
// formula (a leading =, +, -, @, tab or CR) is prefixed with a single quote so it renders as
// plain text on open.
func csvSafe(s string) string {
	if s == "" {
		return s
	}
	switch s[0] {
	case '=', '+', '-', '@', '\t', '\r':
		return "'" + s
	}
	return s
}

// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
	ctx := c.Request.Context()
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	acc, err := s.accounts.GetByID(ctx, id)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	view := adminconsole.UserDetailView{
		ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
		TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
		CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
	}
	if acc.MergedInto != uuid.Nil {
		view.MergedInto = acc.MergedInto.String()
	}
	if !acc.FlaggedHighRateAt.IsZero() {
		view.FlaggedHighRateAt = fmtTime(acc.FlaggedHighRateAt)
	}
	if view.HasStats {
		if st, err := s.accounts.GetStats(ctx, id); err == nil {
			view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints, Moves: st.Moves, HintsUsed: st.HintsUsed}
		}
	}
	if ids, err := s.accounts.Identities(ctx, id); err == nil {
		for _, idn := range ids {
			if idn.Kind == account.KindEmail {
				view.HasEmail = true
			}
			view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
		}
	}
	if info, err := s.accounts.DeletionInfo(ctx, id); err == nil {
		if info.LastLoginAt != nil {
			view.LastLoginAt = fmtTime(*info.LastLoginAt)
		}
		view.LastLoginIP = info.LastLoginIP
		if info.DeletedAt != nil {
			view.Deleted = true
			view.DeletedAt = fmtTime(*info.DeletedAt)
			view.DeletedName = info.DeletedDisplayName
		}
	}
	if rets, err := s.accounts.RetainedIdentities(ctx, id); err == nil {
		for _, r := range rets {
			view.Retained = append(view.Retained, adminconsole.RetainedRow{
				Kind: r.Kind, ExternalID: r.ExternalID, Reason: r.Reason,
				Confirmed: r.Confirmed, LinkedAt: fmtTime(r.LinkedAt), DetachedAt: fmtTime(r.DetachedAt),
			})
		}
	}
	if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
		view.TelegramID = tg
	}
	if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
		view.VKID = vk
	}
	if games, err := s.games.ListForAccount(ctx, id); err == nil {
		for _, g := range games {
			view.Games = append(view.Games, gameRow(g))
		}
	}
	if pts, err := s.games.MoveDurationByOrdinal(ctx, id); err == nil && len(pts) > 0 {
		cps := make([]adminconsole.ChartPoint, len(pts))
		for i, p := range pts {
			cps[i] = adminconsole.ChartPoint{Ordinal: p.Ordinal, Min: p.MinSecs, Max: p.MaxSecs, Avg: p.AvgSecs}
		}
		view.MoveChart = adminconsole.MoveDurationChart(cps)
	}
	if susp, blocked, err := s.accounts.CurrentSuspension(ctx, id); err == nil && blocked {
		view.Suspension = adminconsole.SuspensionView{
			Blocked: true, Permanent: susp.Permanent(), BlockedAt: fmtTime(susp.BlockedAt),
			ReasonEn: susp.ReasonEn, ReasonRu: susp.ReasonRu,
		}
		if susp.BlockedUntil != nil {
			view.Suspension.Until = fmtTime(*susp.BlockedUntil)
		}
	}
	if reasons, err := s.accounts.ListReasons(ctx); err == nil {
		for _, r := range reasons {
			view.Reasons = append(view.Reasons, adminconsole.ReasonOption{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu})
		}
	}
	if roles, err := s.accounts.ListRoles(ctx, id); err == nil {
		view.Roles = roles
	}
	view.KnownRoles = account.KnownRoles
	if s.social != nil {
		if rels, err := s.social.AdminBlocksBy(ctx, id); err == nil {
			view.Blocks = relationRows(rels)
		}
		if rels, err := s.social.AdminBlockedBy(ctx, id); err == nil {
			view.BlockedBy = relationRows(rels)
		}
		if rels, err := s.social.AdminFriends(ctx, id); err == nil {
			view.Friends = relationRows(rels)
		}
	}
	if s.payments != nil {
		if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
			view.Finance = financeView(id, stmt)
		} else {
			s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
		}
		view.Grant = s.grantForm(ctx)
		if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil {
			view.PurchaseOverride = overrideName(ov)
		}
	}
	s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}

// financeView projects an account's payments statement into the user-card finance panel: where the
// account stands (balances, benefits, lifetime money and chip totals), not what happened when. The
// operations themselves are the ledger section's job, which the panel links to pre-filtered to this
// account — one place renders them, with filters and paging, instead of two.
func financeView(id uuid.UUID, stmt payments.Statement) adminconsole.FinanceView {
	fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
	for _, sg := range stmt.Segments {
		fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
	}
	for _, b := range stmt.Benefits {
		row := adminconsole.BenefitRow{Origin: string(b.Origin), Hints: b.Hints, Forever: b.AdsForever}
		if !b.AdsPaidUntil.IsZero() {
			row.AdsUntil = fmtTime(b.AdsPaidUntil)
		}
		fv.Benefits = append(fv.Benefits, row)
	}
	// The lifetime totals are folded from the history the statement already carries, so the summary
	// costs no extra query. Money is kept per currency: the rails settle in roubles, Votes and Stars,
	// and one sum across them would mean nothing.
	paid, refunded := map[payments.Currency]int64{}, map[payments.Currency]int64{}
	for _, e := range stmt.Ledger {
		if e.ChipsDelta > 0 {
			fv.ChipsBought += e.ChipsDelta
		} else if e.Kind == "spend" {
			fv.ChipsSpent -= e.ChipsDelta
		}
		m := payments.MoneyFromLedgerSnapshot(e.Snapshot)
		if m.Currency() == "" {
			continue
		}
		switch e.Kind {
		case "fund":
			paid[m.Currency()] += m.Minor()
		case "refund":
			refunded[m.Currency()] += m.Minor()
		}
	}
	fv.Paid = fmtMoneyTotals(paid)
	fv.Refunded = fmtMoneyTotals(refunded)
	fv.LedgerQuery = template.URL(url.Values{"user": {id.String()}}.Encode())
	return fv
}

// fmtMoneyTotals renders per-currency minor-unit sums as display strings, in a stable order so the
// panel does not reshuffle between reloads (map iteration is random).
func fmtMoneyTotals(totals map[payments.Currency]int64) []string {
	currencies := make([]string, 0, len(totals))
	for c := range totals {
		currencies = append(currencies, string(c))
	}
	sort.Strings(currencies)
	out := make([]string, 0, len(currencies))
	for _, c := range currencies {
		m, err := payments.MoneyFromMinor(totals[payments.Currency(c)], payments.Currency(c))
		if err != nil {
			continue
		}
		out = append(out, fmtMoney(m))
	}
	return out
}

// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
func overrideName(ov payments.PurchaseOverride) string {
	switch ov {
	case payments.OverrideAllow:
		return "allow"
	case payments.OverrideDeny:
		return "deny"
	default:
		return "default"
	}
}

// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny
// / default) from the user card. "allow" bypasses only the operational rail switch, never the
// security gates; "default" clears the override (deletes the row).
func (s *Server) consoleSetPurchaseOverride(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	back := "/_gm/users/" + id.String()
	if s.payments == nil {
		s.renderConsoleMessage(c, "Unavailable", "payments are not configured", back)
		return
	}
	var ov payments.PurchaseOverride
	switch c.PostForm("override") {
	case "allow":
		ov = payments.OverrideAllow
	case "deny":
		ov = payments.OverrideDeny
	default:
		ov = payments.OverrideDefault
	}
	if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil {
		s.renderConsoleMessage(c, "Update failed", err.Error(), back)
		return
	}
	s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back)
}

// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
	out := make([]adminconsole.RelationRow, 0, len(rels))
	for _, r := range rels {
		out = append(out, adminconsole.RelationRow{AccountID: r.AccountID.String(), DisplayName: r.DisplayName, Date: fmtTime(r.At)})
	}
	return out
}

// consoleUserMessage sends an operator Telegram message to one user.
func (s *Server) consoleUserMessage(c *gin.Context) {
	ctx := c.Request.Context()
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	back := "/_gm/users/" + id.String()
	text := trimForm(c, "text")
	switch {
	case text == "":
		s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back)
	case s.connector == nil:
		s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", back)
	default:
		ext, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram)
		if err != nil {
			s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back)
			return
		}
		delivered, err := s.connector.SendToUser(ctx, ext, text)
		if err != nil {
			s.consoleError(c, err)
			return
		}
		body := "message delivered"
		if !delivered {
			body = "not delivered (the user may not have started the bot)"
		}
		s.renderConsoleMessage(c, "Sent", body, back)
	}
}

// consoleGames renders the paginated games list, optionally filtered by status.
func (s *Server) consoleGames(c *gin.Context) {
	ctx := c.Request.Context()
	status := normalizeGameStatus(c.Query("status"))
	page := consolePage(c)
	total, _ := s.games.CountGames(ctx, status)
	games, err := s.games.ListGames(ctx, status, adminPageSize, (page-1)*adminPageSize)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	view := adminconsole.GamesView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
	for _, g := range games {
		view.Items = append(view.Items, gameRow(g))
	}
	s.renderConsole(c, "games", "games", "Games", view)
}

// consoleGameDetail renders one game with its seats (display names resolved).
func (s *Server) consoleGameDetail(c *gin.Context) {
	ctx := c.Request.Context()
	id, ok := s.consoleUUID(c, "/_gm/games")
	if !ok {
		return
	}
	g, err := s.games.GameByID(ctx, id)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	view := adminconsole.GameDetailView{
		ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion,
		Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
		MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
		FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
		MultipleWordsPerTurn: g.MultipleWordsPerTurn,
	}
	// Resolve seats and detect robot seats; capture the human opponent's timezone, which
	// anchors the robot's sleep window for the next-move ETA.
	oppTZ := ""
	for _, seat := range g.Seats {
		row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
		acc, accErr := s.accounts.GetByID(ctx, seat.AccountID)
		if accErr == nil {
			row.DisplayName = acc.DisplayName
		}
		if isRobot, _ := s.accounts.IsRobot(ctx, seat.AccountID); isRobot {
			row.IsRobot = true
			view.HasRobot = true
		} else if accErr == nil {
			oppTZ = acc.TimeZone
		}
		view.Seats = append(view.Seats, row)
	}
	// For each robot seat, surface the game's deterministic play-to-win intent and — while
	// it is that robot's turn — the scheduled next-move ETA, both derived from the bag seed.
	if view.HasRobot {
		view.RobotTargetPct = robot.PlayToWinTargetPercent
		if seed, turnStartedAt, schedErr := s.games.RobotSchedule(ctx, g.ID); schedErr == nil {
			now := time.Now().UTC()
			for i := range view.Seats {
				if !view.Seats[i].IsRobot {
					continue
				}
				if robot.PlayToWin(seed) {
					view.Seats[i].RobotIntent = "play to win"
				} else {
					view.Seats[i].RobotIntent = "play to lose"
				}
				if g.Status == game.StatusActive && g.ToMove == view.Seats[i].Seat {
					view.Seats[i].NextMove = robotETA(robot.NextMoveAt(seed, g.MoveCount, turnStartedAt, oppTZ), now)
				}
			}
		}
	}
	// First-move draw (docs/ARCHITECTURE.md §6) and the step-by-step replay timeline. Both are
	// best-effort: a game that predates the draw or cannot be replayed still renders its
	// summary and seats.
	names := make(map[string]string, len(view.Seats))
	for _, st := range view.Seats {
		names[st.AccountID] = st.DisplayName
	}
	if draws, derr := s.games.SetupDraws(ctx, g.ID); derr == nil {
		for _, d := range draws {
			row := adminconsole.SetupDrawRow{Round: d.Round, Letter: strings.ToUpper(d.Letter), Blank: d.Blank, Rank: d.Rank}
			if d.Account == uuid.Nil {
				row.Name = "(opponent)"
			} else {
				row.Name = names[d.Account.String()]
				row.AccountID = d.Account.String()
			}
			view.SetupDraws = append(view.SetupDraws, row)
		}
	}
	if len(view.Seats) > 0 {
		view.FirstMover = view.Seats[0].DisplayName
	}
	if tl, terr := s.games.ReplayTimeline(ctx, g.ID); terr == nil {
		if rj, jerr := buildReplayJSON(g.Variant, tl, view.Seats); jerr == nil {
			view.ReplayJSON = rj
			view.HasReplay = len(tl.Steps) > 0
		}
	}
	s.renderConsole(c, "game_detail", "games", "Game", view)
}

// robotETA formats a robot's scheduled next-move instant as an absolute UTC time plus a
// relative estimate, e.g. "≈ 14:37 UTC (in ~7 min)"; a past instant reads "(due now)".
func robotETA(at, now time.Time) string {
	mins := int(at.Sub(now).Round(time.Minute).Minutes())
	rel := fmt.Sprintf("in ~%d min", mins)
	if mins <= 0 {
		rel = "due now"
	}
	return fmt.Sprintf("≈ %s UTC (%s)", at.UTC().Format("15:04"), rel)
}

// consoleComplaints renders the paginated complaint review queue.
func (s *Server) consoleComplaints(c *gin.Context) {
	ctx := c.Request.Context()
	status := normalizeComplaintStatus(c.Query("status"))
	page := consolePage(c)
	total, _ := s.games.CountComplaints(ctx, status)
	rows, err := s.games.ListComplaints(ctx, status, adminPageSize, (page-1)*adminPageSize)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	view := adminconsole.ComplaintsView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
	for _, cp := range rows {
		view.Items = append(view.Items, adminconsole.ComplaintRow{
			ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), WasValid: cp.WasValid,
			Status: cp.Status, Disposition: cp.Disposition, CreatedAt: fmtTime(cp.CreatedAt),
		})
	}
	s.renderConsole(c, "complaints", "complaints", "Complaints", view)
}

// consoleComplaintDetail renders one complaint with its resolution form.
func (s *Server) consoleComplaintDetail(c *gin.Context) {
	ctx := c.Request.Context()
	id, ok := s.consoleUUID(c, "/_gm/complaints")
	if !ok {
		return
	}
	cp, err := s.games.GetComplaint(ctx, id)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsole(c, "complaint_detail", "complaints", "Complaint", adminconsole.ComplaintDetailView{
		ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), DictVersion: cp.DictVersion,
		WasValid: cp.WasValid, Note: cp.Note, Status: cp.Status, Disposition: cp.Disposition,
		ResolutionNote: cp.ResolutionNote, CreatedAt: fmtTime(cp.CreatedAt), ResolvedAt: fmtTimePtr(cp.ResolvedAt),
		GameID: cp.GameID.String(), Resolved: cp.Status == game.StatusComplaintResolved,
	})
}

// consoleResolveComplaint resolves a complaint with the chosen disposition.
func (s *Server) consoleResolveComplaint(c *gin.Context) {
	ctx := c.Request.Context()
	id, ok := s.consoleUUID(c, "/_gm/complaints")
	if !ok {
		return
	}
	disposition := c.PostForm("disposition")
	if _, err := s.games.ResolveComplaint(ctx, id, disposition, trimForm(c, "note")); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Resolved", "complaint resolved as "+disposition, "/_gm/complaints/"+id.String())
}

// consoleDictionary renders the resident versions and the pending wordlist changes.
func (s *Server) consoleDictionary(c *gin.Context) {
	view := adminconsole.DictionaryView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
	if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil {
		for _, ch := range changes {
			action := "remove"
			if ch.Add {
				action = "add"
			}
			view.Changes = append(view.Changes, adminconsole.DictChangeRow{Variant: ch.Variant.String(), Word: ch.Word, Action: action, ResolvedAt: fmtTime(ch.ResolvedAt)})
		}
	}
	s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view)
}

// previewSampleCap bounds how many added/removed words the update preview lists per
// variant; the full totals are always shown.
const previewSampleCap = 200

// largeRemovalThreshold flags a removal large enough that the operator should pause
// before confirming: a rebuilt dictionary normally drops a handful of words, not
// thousands.
const largeRemovalThreshold = 1000

// consoleUploadDictionary accepts a scrabble-dawg release archive, stages it under
// the dictionary directory and renders the per-variant word diff against the active
// dictionary. The operator confirms it with consoleInstallDictionary. The archive
// is bounded, its file name fixes the version, and a version already resident is
// rejected (versions are immutable).
func (s *Server) consoleUploadDictionary(c *gin.Context) {
	c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, dictadmin.MaxArchiveBytes)
	file, header, err := c.Request.FormFile("archive")
	if err != nil {
		s.renderConsoleMessage(c, "Upload failed", "choose a scrabble-dawg-vX.Y.Z.tar.gz archive (max 64 MiB)", "/_gm/dictionary")
		return
	}
	defer func() { _ = file.Close() }()

	version, err := dictadmin.ParseVersionFromName(header.Filename)
	if err != nil {
		s.renderConsoleMessage(c, "Upload failed", "the file name must be scrabble-dawg-vX.Y.Z.tar.gz", "/_gm/dictionary")
		return
	}
	if s.versionResident(version) {
		s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary")
		return
	}

	mgr := dictadmin.New(s.dictDir)
	token, variants, err := mgr.Stage(file)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	if len(variants) != len(engine.Variants()) {
		mgr.Discard(token)
		s.renderConsoleMessage(c, "Incomplete archive",
			fmt.Sprintf("the archive carries %d of %d variants; a release must contain all of them", len(variants), len(engine.Variants())),
			"/_gm/dictionary")
		return
	}

	staged, err := mgr.StagedDir(token)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	rows, err := s.diffStaged(staged, variants)
	if err != nil {
		mgr.Discard(token)
		s.consoleError(c, err)
		return
	}
	s.renderConsole(c, "dictionary_preview", "dictionary", "Dictionary update", adminconsole.DictionaryPreviewView{
		Version:       version,
		Token:         token,
		ActiveVersion: s.games.ActiveVersion(),
		Variants:      rows,
	})
}

// consoleInstallDictionary promotes a staged upload into its version directory,
// loads it into the registry and makes it the active version new games pin. The
// version is taken from the (editable) form field, re-validated and checked for
// immutability.
func (s *Server) consoleInstallDictionary(c *gin.Context) {
	token := trimForm(c, "token")
	version := trimForm(c, "version")
	if !dictadmin.ValidVersion(version) {
		s.renderConsoleMessage(c, "Install failed", "the version must look like vX.Y.Z", "/_gm/dictionary")
		return
	}
	if s.versionResident(version) {
		s.renderConsoleMessage(c, "Already resident", fmt.Sprintf("version %s is already loaded; versions are immutable", version), "/_gm/dictionary")
		return
	}

	mgr := dictadmin.New(s.dictDir)
	dest, err := mgr.Install(token, version)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	loaded, err := s.registry.LoadAvailable(dest, version)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	if len(loaded) != len(engine.Variants()) {
		s.renderConsoleMessage(c, "Install incomplete",
			fmt.Sprintf("loaded %d of %d variants from %s", len(loaded), len(engine.Variants()), version), "/_gm/dictionary")
		return
	}
	if err := s.games.SetActiveVersion(c.Request.Context(), version); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Updated",
		fmt.Sprintf("dictionary %s installed and activated; new games now use it, games in progress keep their version", version),
		"/_gm/dictionary")
}

// diffStaged computes the word diff of each staged variant against the active
// dictionary, projected into the preview rows.
func (s *Server) diffStaged(staged string, variants []engine.Variant) ([]adminconsole.VariantDiffRow, error) {
	active := s.games.ActiveVersion()
	rows := make([]adminconsole.VariantDiffRow, 0, len(variants))
	for _, v := range variants {
		newFinder, err := engine.OpenFinder(staged, v)
		if err != nil {
			return nil, err
		}
		oldFinder, err := s.registry.Finder(v, active)
		if err != nil {
			_ = newFinder.Close()
			return nil, fmt.Errorf("active dictionary %s/%s not resident: %w", v, active, err)
		}
		diff, err := engine.DiffWords(v, oldFinder, newFinder)
		_ = newFinder.Close()
		if err != nil {
			return nil, err
		}
		rows = append(rows, diffRow(v, diff))
	}
	return rows, nil
}

// diffRow projects a variant's word diff into its preview row, capping the sample
// lists and flagging a large removal.
func diffRow(v engine.Variant, d engine.WordDiff) adminconsole.VariantDiffRow {
	added, addedTrunc := sampleWords(d.Added)
	removed, removedTrunc := sampleWords(d.Removed)
	return adminconsole.VariantDiffRow{
		Variant:          v.String(),
		AddedCount:       len(d.Added),
		RemovedCount:     len(d.Removed),
		AddedSample:      added,
		RemovedSample:    removed,
		AddedTruncated:   addedTrunc,
		RemovedTruncated: removedTrunc,
		LargeRemoval:     len(d.Removed) >= largeRemovalThreshold,
	}
}

// sampleWords returns at most previewSampleCap words and whether the list was cut.
func sampleWords(words []string) ([]string, bool) {
	if len(words) <= previewSampleCap {
		return words, false
	}
	return words[:previewSampleCap], true
}

// versionResident reports whether version is loaded for any variant (covering the
// flat seed and every uploaded subdirectory), backing the immutability guard.
func (s *Server) versionResident(version string) bool {
	for _, v := range engine.Variants() {
		if _, err := s.registry.Finder(v, version); err == nil {
			return true
		}
	}
	return false
}

// consoleApplyChanges marks a variant's pending accepted changes applied in a
// reloaded version.
func (s *Server) consoleApplyChanges(c *gin.Context) {
	variant, err := engine.ParseVariant(c.PostForm("variant"))
	if err != nil {
		s.renderConsoleMessage(c, "Apply failed", "unknown variant", "/_gm/dictionary")
		return
	}
	version := trimForm(c, "version")
	if version == "" {
		s.renderConsoleMessage(c, "Apply failed", "a version is required", "/_gm/dictionary")
		return
	}
	n, err := s.games.MarkChangesApplied(c.Request.Context(), variant, version)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Applied", fmt.Sprintf("marked %d change(s) applied in %q", n, version), "/_gm/dictionary")
}

// consoleBroadcast renders the operator-broadcast form.
func (s *Server) consoleBroadcast(c *gin.Context) {
	s.renderConsole(c, "broadcast", "broadcast", "Broadcast", adminconsole.BroadcastView{ConnectorEnabled: s.connector != nil})
}

// consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) {
	text := trimForm(c, "text")
	switch {
	case text == "":
		s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
	case s.connector == nil:
		s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
	default:
		delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
		if err != nil {
			s.consoleError(c, err)
			return
		}
		body := "posted to the game channel"
		if !delivered {
			body = "not delivered (the bot has no game channel configured)"
		}
		s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
	}
}

// consoleThrottled renders the rate-limit observability page: the recent
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
// and the accounts currently carrying the soft high-rate flag.
func (s *Server) consoleThrottled(c *gin.Context) {
	ctx := c.Request.Context()
	var view adminconsole.ThrottledView
	if s.ratewatch != nil {
		cfg := s.ratewatch.Config()
		view.FlagThreshold = cfg.FlagThreshold
		view.FlagWindow = cfg.FlagWindow.String()
		for _, ep := range s.ratewatch.Recent() {
			row := adminconsole.ThrottleEpisodeRow{
				Class: ep.Class, Key: ep.Key, Rejected: ep.Rejected,
				FirstSeen: fmtTime(ep.FirstSeen), LastSeen: fmtTime(ep.LastSeen),
			}
			if ep.Class == ratewatch.ClassUser {
				if id, err := uuid.Parse(ep.Key); err == nil {
					row.UserID = id.String()
				}
			}
			view.Episodes = append(view.Episodes, row)
		}
	}
	if s.banview != nil {
		for _, b := range s.banview.Recent() {
			view.Bans = append(view.Bans, adminconsole.BanRow{
				IP: b.IP, Reason: b.Reason, Since: fmtTime(b.Since), Expires: fmtTime(b.Expires),
			})
		}
	}
	flagged, err := s.accounts.ListFlaggedHighRate(ctx)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	for _, fa := range flagged {
		view.Flagged = append(view.Flagged, adminconsole.FlaggedAccountRow{
			ID: fa.ID.String(), DisplayName: fa.DisplayName, FlaggedAt: fmtTime(fa.FlaggedHighRateAt),
		})
	}
	s.renderConsole(c, "throttled", "throttled", "Throttled", view)
}

// consoleUnban lifts a temporary IP ban — the operator's manual override. The
// gateway applies it on its next active-ban sync, so the ban clears within the
// sync interval rather than immediately.
func (s *Server) consoleUnban(c *gin.Context) {
	ip := trimForm(c, "ip")
	if ip == "" {
		s.renderConsoleMessage(c, "Invalid", "an IP address is required", "/_gm/throttled")
		return
	}
	if s.banview != nil {
		s.banview.RequestUnban(ip)
	}
	s.renderConsoleMessage(c, "Unban requested", fmt.Sprintf("%s will be unbanned on the next gateway sync", ip), "/_gm/throttled")
}

// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
// reversible review action.
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	if err := s.accounts.ClearHighRateFlag(c.Request.Context(), id); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}

// consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable.
func (s *Server) consoleRemoveEmail(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	back := "/_gm/users/" + id.String()
	switch err := s.accounts.RemoveEmailIdentity(c.Request.Context(), id); {
	case errors.Is(err, account.ErrLastIdentity):
		s.renderConsoleMessage(c, "Can't remove", "the email is this account's only identity — removing it would leave the account unreachable", back)
	case err != nil:
		s.consoleError(c, err)
	default:
		s.renderConsoleMessage(c, "Removed", "the email identity was erased and the address freed", back)
	}
}

// consoleDeleteUser deletes an account from the console — the operator-initiated equivalent
// of the in-app deletion (legal retention, not erasure): the account is tombstoned, its
// credentials journalled + freed, its live surfaces anonymised, and its sessions revoked.
func (s *Server) consoleDeleteUser(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	back := "/_gm/users/" + id.String()
	if err := s.deleteAccount(c.Request.Context(), id); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Deleted", "the account was deleted: its credentials were journalled and freed, its data anonymised, and its sessions revoked", back)
}

// consoleBlockUser manually blocks an account: it records the suspension (permanent or until a
// parsed deadline, snapshotting the chosen reason's en/ru text) and forfeits the player's active
// games, removing them from matchmaking. The block takes effect on the player's next request.
func (s *Server) consoleBlockUser(c *gin.Context) {
	ctx := c.Request.Context()
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	back := "/_gm/users/" + id.String()
	until, ok := parseSuspendUntil(trimForm(c, "duration"), trimForm(c, "until"))
	if !ok {
		s.renderConsoleMessage(c, "Invalid duration", "pick a preset or a future custom date/time (UTC)", back)
		return
	}
	var reasonEn, reasonRu string
	var reasonID *uuid.UUID
	if rid := trimForm(c, "reason"); rid != "" {
		parsed, err := uuid.Parse(rid)
		if err != nil {
			s.renderConsoleMessage(c, "Invalid reason", "the selected reason is not valid", back)
			return
		}
		reason, err := s.accounts.GetReason(ctx, parsed)
		if err != nil {
			s.consoleError(c, err)
			return
		}
		reasonEn, reasonRu, reasonID = reason.TextEn, reason.TextRu, &reason.ID
	}
	if _, err := s.accounts.Suspend(ctx, id, until, reasonEn, reasonRu, reasonID); err != nil {
		s.consoleError(c, err)
		return
	}
	forfeited, err := s.games.ForfeitAllForAccount(ctx, id)
	if err != nil {
		s.consoleError(c, err)
		return
	}
	// Re-evaluate the player's moderated-chat write access: a block mutes them in
	// the discussion chat if they are currently in it.
	s.publishChatAccessChange(id)
	s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back)
}

// consoleUnblockUser lifts an account's block (temporary or permanent). Games already lost at
// block time are not restored.
func (s *Server) consoleUnblockUser(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/users")
	if !ok {
		return
	}
	if err := s.accounts.LiftSuspension(c.Request.Context(), id); err != nil {
		s.consoleError(c, err)
		return
	}
	// Re-evaluate the player's moderated-chat write access: an unblock restores it
	// (unless they are still chat-muted) for a member currently in the chat.
	s.publishChatAccessChange(id)
	s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}

// parseSuspendUntil maps the block form's duration choice to an expiry instant, returning nil for
// a permanent block. A custom choice parses the datetime-local value as UTC and must be in the
// future. It reports false for an unrecognised choice or an invalid/past custom date.
func parseSuspendUntil(choice, custom string) (*time.Time, bool) {
	now := time.Now().UTC()
	switch choice {
	case "permanent":
		return nil, true
	case "1d":
		t := now.AddDate(0, 0, 1)
		return &t, true
	case "3d":
		t := now.AddDate(0, 0, 3)
		return &t, true
	case "1w":
		t := now.AddDate(0, 0, 7)
		return &t, true
	case "1m":
		t := now.AddDate(0, 1, 0)
		return &t, true
	case "custom":
		for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05"} {
			if t, err := time.Parse(layout, custom); err == nil {
				if !t.After(now) {
					return nil, false
				}
				return &t, true
			}
		}
		return nil, false
	default:
		return nil, false
	}
}

// consoleReasons renders the operator-editable suspension-reason picklist.
func (s *Server) consoleReasons(c *gin.Context) {
	reasons, err := s.accounts.ListReasons(c.Request.Context())
	if err != nil {
		s.consoleError(c, err)
		return
	}
	var view adminconsole.ReasonsView
	for _, r := range reasons {
		view.Items = append(view.Items, adminconsole.ReasonRow{ID: r.ID.String(), TextEn: r.TextEn, TextRu: r.TextRu, CreatedAt: fmtTime(r.CreatedAt)})
	}
	s.renderConsole(c, "reasons", "reasons", "Reasons", view)
}

// consoleCreateReason adds a suspension-reason picklist entry; both languages are required.
func (s *Server) consoleCreateReason(c *gin.Context) {
	en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
	if en == "" || ru == "" {
		s.renderConsoleMessage(c, "Nothing added", "both English and Russian text are required", "/_gm/reasons")
		return
	}
	if _, err := s.accounts.CreateReason(c.Request.Context(), en, ru); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Added", "reason added", "/_gm/reasons")
}

// consoleUpdateReason rewrites a reason's English and Russian text. Existing blocks keep their
// snapshot, so the change only affects future blocks.
func (s *Server) consoleUpdateReason(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/reasons")
	if !ok {
		return
	}
	en, ru := trimForm(c, "text_en"), trimForm(c, "text_ru")
	if en == "" || ru == "" {
		s.renderConsoleMessage(c, "Not changed", "both English and Russian text are required", "/_gm/reasons")
		return
	}
	if _, err := s.accounts.UpdateReason(c.Request.Context(), id, en, ru); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Updated", "reason updated", "/_gm/reasons")
}

// consoleDeleteReason removes a reason from the picklist. Past blocks keep their text snapshot.
func (s *Server) consoleDeleteReason(c *gin.Context) {
	id, ok := s.consoleUUID(c, "/_gm/reasons")
	if !ok {
		return
	}
	if err := s.accounts.DeleteReason(c.Request.Context(), id); err != nil {
		s.consoleError(c, err)
		return
	}
	s.renderConsoleMessage(c, "Deleted", "reason deleted", "/_gm/reasons")
}

// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
	out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
	for _, v := range engine.Variants() {
		vv := adminconsole.VariantVersions{Variant: v.String(), Versions: s.registry.Versions(v)}
		if latest, _, err := s.registry.Latest(v); err == nil {
			vv.Latest = latest
		}
		out = append(out, vv)
	}
	return out
}

// renderConsole renders a console page into a buffer, then writes it; a render
// failure is logged and reported as 500 without emitting a partial document.
func (s *Server) renderConsole(c *gin.Context, page, nav, title string, data any) {
	var buf bytes.Buffer
	if err := s.console.Render(&buf, page, adminconsole.PageData{Title: title, ActiveNav: nav, Data: data}); err != nil {
		s.log.Error("admin console render", zap.String("page", page), zap.Error(err))
		c.String(http.StatusInternalServerError, "render error")
		return
	}
	c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}

// renderConsoleMessage renders the post-action result page with a back link.
func (s *Server) renderConsoleMessage(c *gin.Context, heading, body, back string) {
	s.renderConsole(c, "message", "", heading, adminconsole.MessageView{Heading: heading, Body: body, Back: back})
}

// consoleError logs an unexpected console error and renders it on the message page.
// The console is operator-only (gateway Basic-Auth), so the message is shown as-is.
func (s *Server) consoleError(c *gin.Context, err error) {
	s.log.Error("admin console", zap.String("path", c.FullPath()), zap.Error(err))
	s.renderConsole(c, "message", "", "Error", adminconsole.MessageView{Heading: "Error", Body: err.Error(), Back: "/_gm/"})
}

// consoleUUID parses the :id path parameter, rendering an invalid-id message and
// returning false when it is not a UUID.
func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
	id, err := uuid.Parse(c.Param("id"))
	if err != nil {
		s.renderConsoleMessage(c, "Invalid id", "the id in the URL is not valid", back)
		return uuid.UUID{}, false
	}
	return id, true
}

// gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow {
	return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
}

// trimForm returns the trimmed value of a posted form field.
func trimForm(c *gin.Context, name string) string {
	return strings.TrimSpace(c.PostForm(name))
}

// consolePage parses the 1-based ?page query parameter, defaulting to 1.
func consolePage(c *gin.Context) int {
	if n, err := strconv.Atoi(c.Query("page")); err == nil && n > 1 {
		return n
	}
	return 1
}

// normalizeGameStatus keeps only a recognised game status filter, else "" (all).
func normalizeGameStatus(s string) string {
	switch s {
	case game.StatusActive, game.StatusFinished, game.StatusOpen:
		return s
	}
	return ""
}

// normalizeComplaintStatus keeps only a recognised complaint status filter, else
// "" (all).
func normalizeComplaintStatus(s string) string {
	switch s {
	case game.StatusComplaintOpen, game.StatusComplaintResolved:
		return s
	}
	return ""
}

// shortID abbreviates an account id for a table cell: enough to recognise the same buyer across
// rows at a glance, while the link still carries the whole id. Money tables have no room for a full
// uuid, and a generic label there ("card") read as a payment card on the very pages about payments.
func shortID(id string) string {
	const n = 8
	if len(id) <= n {
		return id
	}
	return id[:n]
}

// fmtTime formats a timestamp for display, or "" when zero.
func fmtTime(t time.Time) string {
	if t.IsZero() {
		return ""
	}
	return t.UTC().Format("2006-01-02 15:04")
}

// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
func fmtTimeIn(t time.Time, tz string) string {
	if t.IsZero() {
		return ""
	}
	return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
}

// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
	if t == nil {
		return ""
	}
	return fmtTime(*t)
}
