package transcode

import (
	flatbuffers "github.com/google/flatbuffers/go"

	"scrabble/gateway/internal/backendclient"
	fb "scrabble/pkg/fbs/scrabblefb"
	"scrabble/pkg/wire"
)

// The encoders build the FlatBuffers response payloads from the backend's typed
// responses. FlatBuffers is built bottom-up: every string and child vector is
// created before the table that references it, and no two tables/vectors are
// under construction at once.

// encodeSession builds a Session payload.
func encodeSession(s backendclient.SessionResp) []byte {
	b := flatbuffers.NewBuilder(128)
	token := b.CreateString(s.Token)
	uid := b.CreateString(s.UserID)
	name := b.CreateString(s.DisplayName)
	fb.SessionStart(b)
	fb.SessionAddToken(b, token)
	fb.SessionAddUserId(b, uid)
	fb.SessionAddIsGuest(b, s.IsGuest)
	fb.SessionAddDisplayName(b, name)
	b.Finish(fb.SessionEnd(b))
	return b.FinishedBytes()
}

// encodeAck builds an Ack payload.
func encodeAck(ok bool) []byte {
	b := flatbuffers.NewBuilder(16)
	fb.AckStart(b)
	fb.AckAddOk(b, ok)
	b.Finish(fb.AckEnd(b))
	return b.FinishedBytes()
}

// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider
// launch URL the client opens.
func encodeWalletOrder(o backendclient.WalletOrderResp) []byte {
	b := flatbuffers.NewBuilder(128)
	oid := b.CreateString(o.OrderID)
	url := b.CreateString(o.RedirectURL)
	fb.WalletOrderResponseStart(b)
	fb.WalletOrderResponseAddOrderId(b, oid)
	fb.WalletOrderResponseAddRedirectUrl(b, url)
	b.Finish(fb.WalletOrderResponseEnd(b))
	return b.FinishedBytes()
}

// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which
// deletion step-up the account uses ("email" | "phrase").
func encodeDeleteRequestResult(method string) []byte {
	b := flatbuffers.NewBuilder(32)
	m := b.CreateString(method)
	fb.AccountDeleteRequestResultStart(b)
	fb.AccountDeleteRequestResultAddMethod(b, m)
	b.Finish(fb.AccountDeleteRequestResultEnd(b))
	return b.FinishedBytes()
}

// encodeConfirmLinkResult builds an EmailConfirmLinkResult payload, embedding the
// minted Session for a login. All strings and the nested Session table are built
// before the result table is opened.
func encodeConfirmLinkResult(r backendclient.ConfirmLinkResp) []byte {
	b := flatbuffers.NewBuilder(160)
	purpose := b.CreateString(r.Purpose)
	status := b.CreateString(r.Status)
	var session flatbuffers.UOffsetT
	if r.Session != nil {
		token := b.CreateString(r.Session.Token)
		uid := b.CreateString(r.Session.UserID)
		name := b.CreateString(r.Session.DisplayName)
		fb.SessionStart(b)
		fb.SessionAddToken(b, token)
		fb.SessionAddUserId(b, uid)
		fb.SessionAddIsGuest(b, r.Session.IsGuest)
		fb.SessionAddDisplayName(b, name)
		session = fb.SessionEnd(b)
	}
	fb.EmailConfirmLinkResultStart(b)
	fb.EmailConfirmLinkResultAddPurpose(b, purpose)
	fb.EmailConfirmLinkResultAddStatus(b, status)
	if r.Session != nil {
		fb.EmailConfirmLinkResultAddSession(b, session)
	}
	b.Finish(fb.EmailConfirmLinkResultEnd(b))
	return b.FinishedBytes()
}

// encodeProfile builds a Profile payload, including the advertising-banner block
// when the backend marked the viewer eligible.
// encodeWallet builds the Wallet payload: the visible chip segments and the context-applicable
// benefits. Each WalletSegment table (and its source string) is built before the segments vector
// is opened, per FlatBuffers' rule against a nested table while another is under construction.
func encodeWallet(w backendclient.WalletResp) []byte {
	b := flatbuffers.NewBuilder(128)
	segs := make([]flatbuffers.UOffsetT, len(w.Segments))
	for i, seg := range w.Segments {
		src := b.CreateString(seg.Source)
		fb.WalletSegmentStart(b)
		fb.WalletSegmentAddSource(b, src)
		fb.WalletSegmentAddChips(b, int32(seg.Chips))
		fb.WalletSegmentAddSpendable(b, seg.Spendable)
		segs[i] = fb.WalletSegmentEnd(b)
	}
	fb.WalletStartSegmentsVector(b, len(segs))
	for i := len(segs) - 1; i >= 0; i-- {
		b.PrependUOffsetT(segs[i])
	}
	segVec := b.EndVector(len(segs))
	fb.WalletStart(b)
	fb.WalletAddSegments(b, segVec)
	fb.WalletAddAdsForever(b, w.AdsForever)
	fb.WalletAddAdsPaidUntilMs(b, w.AdsPaidUntil)
	fb.WalletAddHints(b, int32(w.Hints))
	fb.WalletAddRewardChips(b, int32(w.RewardChips))
	b.Finish(fb.WalletEnd(b))
	return b.FinishedBytes()
}

// encodeCatalog builds a Catalog payload: the storefront products for the caller's context, each
// with its atom composition (built bottom-up — every product's atoms vector is finished before
// its product table, and every product before the products vector).
func encodeCatalog(cat backendclient.CatalogResp) []byte {
	b := flatbuffers.NewBuilder(256)
	prods := make([]flatbuffers.UOffsetT, len(cat.Products))
	for i, p := range cat.Products {
		atoms := make([]flatbuffers.UOffsetT, len(p.Atoms))
		for j, a := range p.Atoms {
			at := b.CreateString(a.AtomType)
			fb.CatalogAtomStart(b)
			fb.CatalogAtomAddAtomType(b, at)
			fb.CatalogAtomAddQuantity(b, int32(a.Quantity))
			atoms[j] = fb.CatalogAtomEnd(b)
		}
		fb.CatalogProductStartAtomsVector(b, len(atoms))
		for j := len(atoms) - 1; j >= 0; j-- {
			b.PrependUOffsetT(atoms[j])
		}
		atomsVec := b.EndVector(len(atoms))

		kind := b.CreateString(p.Kind)
		pid := b.CreateString(p.ProductID)
		title := b.CreateString(p.Title)
		cur := b.CreateString(p.MoneyCurrency)
		fb.CatalogProductStart(b)
		fb.CatalogProductAddKind(b, kind)
		fb.CatalogProductAddProductId(b, pid)
		fb.CatalogProductAddTitle(b, title)
		fb.CatalogProductAddChips(b, int32(p.Chips))
		fb.CatalogProductAddMoneyAmount(b, p.MoneyAmount)
		fb.CatalogProductAddMoneyCurrency(b, cur)
		fb.CatalogProductAddAtoms(b, atomsVec)
		prods[i] = fb.CatalogProductEnd(b)
	}
	fb.CatalogStartProductsVector(b, len(prods))
	for i := len(prods) - 1; i >= 0; i-- {
		b.PrependUOffsetT(prods[i])
	}
	prodVec := b.EndVector(len(prods))
	fb.CatalogStart(b)
	fb.CatalogAddProducts(b, prodVec)
	b.Finish(fb.CatalogEnd(b))
	return b.FinishedBytes()
}

func encodeProfile(p backendclient.ProfileResp) []byte {
	b := flatbuffers.NewBuilder(192)
	uid := b.CreateString(p.UserID)
	name := b.CreateString(p.DisplayName)
	lang := b.CreateString(p.PreferredLanguage)
	tz := b.CreateString(p.TimeZone)
	awayStart := b.CreateString(p.AwayStart)
	awayEnd := b.CreateString(p.AwayEnd)
	email := b.CreateString(p.Email)
	// Build the banner table (and its children) before opening Profile: FlatBuffers
	// forbids a nested table while another is under construction.
	var banner flatbuffers.UOffsetT
	if p.Banner != nil {
		banner = encodeBanner(b, *p.Banner)
	}
	prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
	dictVersions := encodeDictVersions(b, p.DictVersions)
	// The interstitial-ad config table (scalars only), built before Profile is opened.
	var ads flatbuffers.UOffsetT
	if p.Ads != nil {
		fb.AdsInfoStart(b)
		fb.AdsInfoAddCooldownGlobalS(b, int32(p.Ads.CooldownGlobalS))
		fb.AdsInfoAddCooldownVsAiS(b, int32(p.Ads.CooldownVsAiS))
		fb.AdsInfoAddCooldownHintS(b, int32(p.Ads.CooldownHintS))
		fb.AdsInfoAddSuppressed(b, p.Ads.Suppressed)
		ads = fb.AdsInfoEnd(b)
	}
	// The active-game limits table (scalars only), built before Profile is opened.
	var gameLimits flatbuffers.UOffsetT
	if p.GameLimits != nil {
		fb.GameLimitsStart(b)
		fb.GameLimitsAddVsAi(b, int32(p.GameLimits.VsAI))
		fb.GameLimitsAddRandom(b, int32(p.GameLimits.Random))
		fb.GameLimitsAddFriends(b, int32(p.GameLimits.Friends))
		gameLimits = fb.GameLimitsEnd(b)
	}
	fb.ProfileStart(b)
	fb.ProfileAddUserId(b, uid)
	fb.ProfileAddDisplayName(b, name)
	fb.ProfileAddPreferredLanguage(b, lang)
	fb.ProfileAddTimeZone(b, tz)
	fb.ProfileAddHintBalance(b, int32(p.HintBalance))
	fb.ProfileAddBlockChat(b, p.BlockChat)
	fb.ProfileAddBlockFriendRequests(b, p.BlockFriendRequests)
	fb.ProfileAddIsGuest(b, p.IsGuest)
	fb.ProfileAddAwayStart(b, awayStart)
	fb.ProfileAddAwayEnd(b, awayEnd)
	fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
	fb.ProfileAddVariantPreferences(b, prefs)
	fb.ProfileAddEmail(b, email)
	fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
	fb.ProfileAddVkLinked(b, p.VkLinked)
	if dictVersions != 0 {
		fb.ProfileAddDictVersions(b, dictVersions)
	}
	if p.Banner != nil {
		fb.ProfileAddBanner(b, banner)
	}
	if p.Ads != nil {
		fb.ProfileAddAds(b, ads)
	}
	if p.GameLimits != nil {
		fb.ProfileAddGameLimits(b, gameLimits)
	}
	b.Finish(fb.ProfileEnd(b))
	return b.FinishedBytes()
}

// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per
// variant/version pair — and returns its offset, or 0 when there are none (the field is then
// omitted). Every child table and its strings are created before the vector is opened, per the
// FlatBuffers rule against nesting a table under one still being built; the caller invokes it
// before ProfileStart for the same reason.
func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT {
	if len(dvs) == 0 {
		return 0
	}
	offsets := make([]flatbuffers.UOffsetT, len(dvs))
	for i, dv := range dvs {
		variant := b.CreateString(dv.Variant)
		version := b.CreateString(dv.Version)
		fb.DictVersionStart(b)
		fb.DictVersionAddVariant(b, variant)
		fb.DictVersionAddVersion(b, version)
		offsets[i] = fb.DictVersionEnd(b)
	}
	fb.ProfileStartDictVersionsVector(b, len(offsets))
	for i := len(offsets) - 1; i >= 0; i-- {
		b.PrependUOffsetT(offsets[i])
	}
	return b.EndVector(len(offsets))
}

// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
// optional field (the client then reads it as absent).
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
	if s == "" {
		return 0
	}
	return b.CreateString(s)
}

// addOptString adds an optional string slot only when it was created (a 0 offset
// leaves the field absent). add is the generated BannerCampaignAdd* setter.
func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) {
	if off != 0 {
		add(b, off)
	}
}

// encodeBanner builds a BannerInfo table from the resolved banner block and
// returns its offset. It is bottom-up: each campaign's messages vector and table
// are built first, then the campaigns vector, then the BannerInfo table. The
// caller must invoke it with no table under construction.
func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatbuffers.UOffsetT {
	campOffsets := make([]flatbuffers.UOffsetT, len(banner.Campaigns))
	for i, c := range banner.Campaigns {
		msgOffsets := make([]flatbuffers.UOffsetT, len(c.Messages))
		for j, m := range c.Messages {
			msgOffsets[j] = b.CreateString(m)
		}
		fb.BannerCampaignStartMessagesVector(b, len(msgOffsets))
		for j := len(msgOffsets) - 1; j >= 0; j-- {
			b.PrependUOffsetT(msgOffsets[j])
		}
		msgs := b.EndVector(len(msgOffsets))
		// The optional colour strings must be created before the table starts; an
		// empty colour is omitted (offset 0), so the client reads it as absent.
		obg := optString(b, c.OverrideBg)
		ofg := optString(b, c.OverrideFg)
		olink := optString(b, c.OverrideLink)
		obgD := optString(b, c.OverrideBgDark)
		ofgD := optString(b, c.OverrideFgDark)
		olinkD := optString(b, c.OverrideLinkDark)
		fb.BannerCampaignStart(b)
		fb.BannerCampaignAddWeight(b, int32(c.Weight))
		fb.BannerCampaignAddMessages(b, msgs)
		addOptString(b, fb.BannerCampaignAddOverrideBg, obg)
		addOptString(b, fb.BannerCampaignAddOverrideFg, ofg)
		addOptString(b, fb.BannerCampaignAddOverrideLink, olink)
		addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD)
		addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD)
		addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD)
		campOffsets[i] = fb.BannerCampaignEnd(b)
	}
	fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
	for i := len(campOffsets) - 1; i >= 0; i-- {
		b.PrependUOffsetT(campOffsets[i])
	}
	camps := b.EndVector(len(campOffsets))
	t := banner.Timings
	fb.BannerInfoStart(b)
	fb.BannerInfoAddCampaigns(b, camps)
	fb.BannerInfoAddHoldMs(b, int32(t.HoldMs))
	fb.BannerInfoAddEdgePauseMs(b, int32(t.EdgePauseMs))
	fb.BannerInfoAddScrollPxPerSec(b, int32(t.ScrollPxPerSec))
	fb.BannerInfoAddFadeOutMs(b, int32(t.FadeOutMs))
	fb.BannerInfoAddGapMs(b, int32(t.GapMs))
	fb.BannerInfoAddFadeInMs(b, int32(t.FadeInMs))
	return fb.BannerInfoEnd(b)
}

// encodeBlockStatus builds a BlockStatus payload.
func encodeBlockStatus(s backendclient.BlockStatusResp) []byte {
	b := flatbuffers.NewBuilder(128)
	until := b.CreateString(s.Until)
	reason := b.CreateString(s.Reason)
	fb.BlockStatusStart(b)
	fb.BlockStatusAddBlocked(b, s.Blocked)
	fb.BlockStatusAddPermanent(b, s.Permanent)
	fb.BlockStatusAddUntil(b, until)
	fb.BlockStatusAddReason(b, reason)
	b.Finish(fb.BlockStatusEnd(b))
	return b.FinishedBytes()
}

// encodeLinkResult builds a LinkResult payload. A switched-session token
// (a guest initiator whose durable counterpart won) is carried as a nested Session
// for the client to adopt; it is omitted otherwise.
func encodeLinkResult(r backendclient.LinkResultResp) []byte {
	b := flatbuffers.NewBuilder(256)
	status := b.CreateString(r.Status)
	secID := b.CreateString(r.SecondaryUserID)
	secName := b.CreateString(r.SecondaryName)
	hasSession := r.Token != "" && r.Profile != nil
	var sess flatbuffers.UOffsetT
	if hasSession {
		token := b.CreateString(r.Token)
		uid := b.CreateString(r.Profile.UserID)
		name := b.CreateString(r.Profile.DisplayName)
		fb.SessionStart(b)
		fb.SessionAddToken(b, token)
		fb.SessionAddUserId(b, uid)
		fb.SessionAddIsGuest(b, r.Profile.IsGuest)
		fb.SessionAddDisplayName(b, name)
		sess = fb.SessionEnd(b)
	}
	fb.LinkResultStart(b)
	fb.LinkResultAddStatus(b, status)
	fb.LinkResultAddSecondaryUserId(b, secID)
	fb.LinkResultAddSecondaryDisplayName(b, secName)
	fb.LinkResultAddSecondaryGames(b, int32(r.SecondaryGames))
	fb.LinkResultAddSecondaryFriends(b, int32(r.SecondaryFriends))
	if hasSession {
		fb.LinkResultAddSession(b, sess)
	}
	b.Finish(fb.LinkResultEnd(b))
	return b.FinishedBytes()
}

// encodeMoveResult builds a MoveResult payload.
func encodeMoveResult(r backendclient.MoveResultResp) []byte {
	b := flatbuffers.NewBuilder(512)
	move := buildMoveRecord(b, r.Move)
	game := buildGameView(b, r.Game)
	rackBytes := make([]byte, len(r.Rack))
	for i, v := range r.Rack {
		rackBytes[i] = byte(v)
	}
	rack := b.CreateByteVector(rackBytes)
	fb.MoveResultStart(b)
	fb.MoveResultAddMove(b, move)
	fb.MoveResultAddGame(b, game)
	fb.MoveResultAddRack(b, rack)
	fb.MoveResultAddBagLen(b, int32(r.BagLen))
	b.Finish(fb.MoveResultEnd(b))
	return b.FinishedBytes()
}

// encodeState builds a StateView payload. The rack is a vector of alphabet indices and the
// alphabet display table is included only when the backend returned it (the
// client requests it on a per-variant cache miss).
func encodeState(s backendclient.StateResp) []byte {
	b := flatbuffers.NewBuilder(512)
	b.Finish(wire.BuildStateView(b, toWireState(s)))
	return b.FinishedBytes()
}

// toWireState maps a StateResp to the shared wire.StateView.
func toWireState(s backendclient.StateResp) wire.StateView {
	alphabet := make([]wire.AlphabetEntry, len(s.Alphabet))
	for i, e := range s.Alphabet {
		alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
	}
	return wire.StateView{
		Game:                  toWireGame(s.Game),
		Seat:                  s.Seat,
		Rack:                  s.Rack,
		BagLen:                s.BagLen,
		HintsRemaining:        s.HintsRemaining,
		HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
		Alphabet:              alphabet,
	}
}

// encodeMatch builds a MatchResult payload.
func encodeMatch(m backendclient.MatchResp) []byte {
	b := flatbuffers.NewBuilder(512)
	// Enqueue always lands the caller in a game; an open game awaiting an opponent reports
	// matched=false but still carries it, so encode the game whenever it is present (else the
	// client never receives it and cannot navigate in).
	hasGame := m.Game != nil
	var game flatbuffers.UOffsetT
	if hasGame {
		game = buildGameView(b, *m.Game)
	}
	fb.MatchResultStart(b)
	fb.MatchResultAddMatched(b, m.Matched)
	if hasGame {
		fb.MatchResultAddGame(b, game)
	}
	b.Finish(fb.MatchResultEnd(b))
	return b.FinishedBytes()
}

// buildChatMessage builds a ChatMessage table and returns its offset.
func buildChatMessage(b *flatbuffers.Builder, c backendclient.ChatResp) flatbuffers.UOffsetT {
	id := b.CreateString(c.ID)
	gid := b.CreateString(c.GameID)
	sid := b.CreateString(c.SenderID)
	kind := b.CreateString(c.Kind)
	body := b.CreateString(c.Body)
	fb.ChatMessageStart(b)
	fb.ChatMessageAddId(b, id)
	fb.ChatMessageAddGameId(b, gid)
	fb.ChatMessageAddSenderId(b, sid)
	fb.ChatMessageAddKind(b, kind)
	fb.ChatMessageAddBody(b, body)
	fb.ChatMessageAddCreatedAtUnix(b, c.CreatedAtUnix)
	return fb.ChatMessageEnd(b)
}

// encodeChat builds a ChatMessage payload.
func encodeChat(c backendclient.ChatResp) []byte {
	b := flatbuffers.NewBuilder(192)
	b.Finish(buildChatMessage(b, c))
	return b.FinishedBytes()
}

// encodeHintResult builds a HintResult payload.
func encodeHintResult(r backendclient.HintResultResp) []byte {
	b := flatbuffers.NewBuilder(512)
	move := buildMoveRecord(b, r.Move)
	fb.HintResultStart(b)
	fb.HintResultAddMove(b, move)
	fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
	fb.HintResultAddWalletBalance(b, int32(r.WalletBalance))
	b.Finish(fb.HintResultEnd(b))
	return b.FinishedBytes()
}

// encodeEvalResult builds an EvalResult payload.
func encodeEvalResult(r backendclient.EvalResultResp) []byte {
	b := flatbuffers.NewBuilder(256)
	words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
	dir := b.CreateString(r.Dir)
	fb.EvalResultStart(b)
	fb.EvalResultAddLegal(b, r.Legal)
	fb.EvalResultAddScore(b, int32(r.Score))
	fb.EvalResultAddWords(b, words)
	fb.EvalResultAddDir(b, dir)
	b.Finish(fb.EvalResultEnd(b))
	return b.FinishedBytes()
}

// encodeWordCheck builds a WordCheckResult payload.
func encodeWordCheck(r backendclient.WordCheckResp) []byte {
	b := flatbuffers.NewBuilder(64)
	word := b.CreateString(r.Word)
	fb.WordCheckResultStart(b)
	fb.WordCheckResultAddWord(b, word)
	fb.WordCheckResultAddLegal(b, r.Legal)
	b.Finish(fb.WordCheckResultEnd(b))
	return b.FinishedBytes()
}

// encodeDraftView builds a DraftView payload wrapping the player's composition JSON. The
// string is empty for the save acknowledgement (the client ignores that payload).
func encodeDraftView(jsonStr string) []byte {
	b := flatbuffers.NewBuilder(256)
	j := b.CreateString(jsonStr)
	fb.DraftViewStart(b)
	fb.DraftViewAddJson(b, j)
	b.Finish(fb.DraftViewEnd(b))
	return b.FinishedBytes()
}

// encodeHistory builds a History payload (the decoded move journal).
func encodeHistory(r backendclient.HistoryResp) []byte {
	b := flatbuffers.NewBuilder(1024)
	moveOffs := make([]flatbuffers.UOffsetT, len(r.Moves))
	for i, m := range r.Moves {
		moveOffs[i] = buildMoveRecord(b, m)
	}
	fb.HistoryStartMovesVector(b, len(moveOffs))
	for i := len(moveOffs) - 1; i >= 0; i-- {
		b.PrependUOffsetT(moveOffs[i])
	}
	moves := b.EndVector(len(moveOffs))
	gid := b.CreateString(r.GameID)
	fb.HistoryStart(b)
	fb.HistoryAddGameId(b, gid)
	fb.HistoryAddMoves(b, moves)
	b.Finish(fb.HistoryEnd(b))
	return b.FinishedBytes()
}

// encodeGameList builds a GameList payload (the caller's games).
func encodeGameList(r backendclient.GameListResp) []byte {
	b := flatbuffers.NewBuilder(1024)
	gameOffs := make([]flatbuffers.UOffsetT, len(r.Games))
	for i, g := range r.Games {
		gameOffs[i] = buildGameView(b, g)
	}
	fb.GameListStartGamesVector(b, len(gameOffs))
	for i := len(gameOffs) - 1; i >= 0; i-- {
		b.PrependUOffsetT(gameOffs[i])
	}
	games := b.EndVector(len(gameOffs))
	fb.GameListStart(b)
	fb.GameListAddGames(b, games)
	fb.GameListAddAtGameLimit(b, r.AtGameLimit)
	b.Finish(fb.GameListEnd(b))
	return b.FinishedBytes()
}

// encodeChatList builds a ChatList payload (a game's chat history).
func encodeChatList(r backendclient.ChatListResp) []byte {
	b := flatbuffers.NewBuilder(512)
	msgOffs := make([]flatbuffers.UOffsetT, len(r.Messages))
	for i, m := range r.Messages {
		msgOffs[i] = buildChatMessage(b, m)
	}
	fb.ChatListStartMessagesVector(b, len(msgOffs))
	for i := len(msgOffs) - 1; i >= 0; i-- {
		b.PrependUOffsetT(msgOffs[i])
	}
	msgs := b.EndVector(len(msgOffs))
	fb.ChatListStart(b)
	fb.ChatListAddMessages(b, msgs)
	b.Finish(fb.ChatListEnd(b))
	return b.FinishedBytes()
}

// encodeFeedbackState builds a FeedbackState payload, nesting the reply when present.
func encodeFeedbackState(st backendclient.FeedbackStateResp) []byte {
	b := flatbuffers.NewBuilder(128)
	var reply flatbuffers.UOffsetT
	if st.Reply != nil {
		body := b.CreateString(st.Reply.Body)
		fb.FeedbackReplyStart(b)
		fb.FeedbackReplyAddBody(b, body)
		fb.FeedbackReplyAddRepliedAtUnix(b, st.Reply.RepliedAtUnix)
		reply = fb.FeedbackReplyEnd(b)
	}
	reason := b.CreateString(st.BlockedReason)
	fb.FeedbackStateStart(b)
	fb.FeedbackStateAddCanSend(b, st.CanSend)
	fb.FeedbackStateAddBlockedReason(b, reason)
	if st.Reply != nil {
		fb.FeedbackStateAddReply(b, reply)
	}
	b.Finish(fb.FeedbackStateEnd(b))
	return b.FinishedBytes()
}

// encodeFeedbackUnread builds a FeedbackUnread payload.
func encodeFeedbackUnread(u backendclient.FeedbackUnreadResp) []byte {
	b := flatbuffers.NewBuilder(16)
	fb.FeedbackUnreadStart(b)
	fb.FeedbackUnreadAddReplyUnread(b, u.ReplyUnread)
	b.Finish(fb.FeedbackUnreadEnd(b))
	return b.FinishedBytes()
}

// buildGameView builds a GameView table and returns its offset.
func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers.UOffsetT {
	return wire.BuildGameView(b, toWireGame(g))
}

// toWireGame maps a GameResp to the shared wire.GameView.
func toWireGame(g backendclient.GameResp) wire.GameView {
	seats := make([]wire.SeatView, len(g.Seats))
	for i, s := range g.Seats {
		seats[i] = wire.SeatView{
			Seat:        s.Seat,
			AccountID:   s.AccountID,
			Score:       s.Score,
			HintsUsed:   s.HintsUsed,
			IsWinner:    s.IsWinner,
			DisplayName: s.DisplayName,
			Deleted:     s.Deleted,
		}
	}
	return wire.GameView{
		ID:                   g.ID,
		Variant:              g.Variant,
		DictVersion:          g.DictVersion,
		Status:               g.Status,
		Players:              g.Players,
		ToMove:               g.ToMove,
		TurnTimeoutSecs:      g.TurnTimeoutSecs,
		MultipleWordsPerTurn: g.MultipleWordsPerTurn,
		VsAI:                 g.VsAI,
		MoveCount:            g.MoveCount,
		EndReason:            g.EndReason,
		Seats:                seats,
		LastActivityUnix:     g.LastActivityUnix,
		UnreadChat:           g.UnreadChat,
		UnreadMessages:       g.UnreadMessages,
		Kind:                 g.Kind,
		NoRepeatWords:        g.NoRepeatWords,
	}
}

// buildMoveRecord builds a MoveRecord table and returns its offset.
func buildMoveRecord(b *flatbuffers.Builder, m backendclient.MoveRecordResp) flatbuffers.UOffsetT {
	tiles := make([]wire.TileRecord, len(m.Tiles))
	for i, t := range m.Tiles {
		tiles[i] = wire.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank}
	}
	return wire.BuildMoveRecord(b, wire.MoveRecord{
		Player:  m.Player,
		Action:  m.Action,
		Dir:     m.Dir,
		MainRow: m.MainRow,
		MainCol: m.MainCol,
		Tiles:   tiles,
		Words:   m.Words,
		Count:   m.Count,
		Score:   m.Score,
		Total:   m.Total,
	})
}

// buildStringVector builds a vector of strings using the table-specific
// StartXVector function and returns the vector offset.
func buildStringVector(b *flatbuffers.Builder, items []string, start func(*flatbuffers.Builder, int) flatbuffers.UOffsetT) flatbuffers.UOffsetT {
	offs := make([]flatbuffers.UOffsetT, len(items))
	for i, s := range items {
		offs[i] = b.CreateString(s)
	}
	start(b, len(offs))
	for i := len(offs) - 1; i >= 0; i-- {
		b.PrependUOffsetT(offs[i])
	}
	return b.EndVector(len(offs))
}
