// FlatBuffers <-> domain-model codec. The real transport encodes each request table
// and decodes each response/event table here, mirroring the gateway's Go encoders in
// reverse. The screens only ever see the plain model (lib/model), never these wire
// types.

import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import { indexForLetter, letterForIndex, setAlphabet, type AlphabetEntryWire } from './alphabet';
import type { PlacedTile } from './client';
import type {
  AccountRef,
  BlockList,
  OutgoingList,
  RobotBlockEntry,
  RobotFriendRequestEntry,
  Banner,
  AdsConfig,
  GameLimits,
  BannerCampaign,
  BestMove,
  BestMoveTile,
  BlockStatus,
  ChatMessage,
  ColorTriple,
  EvalResult,
  FeedbackState,
  FriendCode,
  ExportUrl,
  GameList,
  GameView,
  GcgExport,
  History,
  HintResult,
  Invitation,
  InvitationInvitee,
  InvitationSettings,
  ConfirmLinkResult,
  LinkResult,
  MatchResult,
  MoveRecord,
  MoveResult,
  Profile,
  Wallet,
  WalletOrder,
  WalletSegment,
  Catalog,
  CatalogProduct,
  CatalogAtom,
  ProfileUpdate,
  PushEvent,
  Seat,
  Session,
  StateView,
  Stats,
  Tile,
  Variant,
  WordCheckResult,
} from './model';

// --- request encoders ---

// buildPlayTile encodes one to-place tile by its alphabet index; a placed blank
// carries its designated letter's index with blank set.
function buildPlayTile(b: Builder, t: PlacedTile, variant: Variant): Offset {
  fb.PlayTile.startPlayTile(b);
  fb.PlayTile.addRow(b, t.row);
  fb.PlayTile.addCol(b, t.col);
  fb.PlayTile.addLetter(b, indexForLetter(variant, t.letter));
  fb.PlayTile.addBlank(b, t.blank);
  return fb.PlayTile.endPlayTile(b);
}

function finish(b: Builder, root: Offset): Uint8Array {
  b.finish(root);
  return b.asUint8Array();
}

export const empty = (): Uint8Array => new Uint8Array();

export function encodeGameAction(gameId: string): Uint8Array {
  const b = new Builder(64);
  const gid = b.createString(gameId);
  fb.GameActionRequest.startGameActionRequest(b);
  fb.GameActionRequest.addGameId(b, gid);
  return finish(b, fb.GameActionRequest.endGameActionRequest(b));
}

export function encodeStateRequest(gameId: string, includeAlphabet: boolean): Uint8Array {
  const b = new Builder(64);
  const gid = b.createString(gameId);
  fb.StateRequest.startStateRequest(b);
  fb.StateRequest.addGameId(b, gid);
  fb.StateRequest.addIncludeAlphabet(b, includeAlphabet);
  return finish(b, fb.StateRequest.endStateRequest(b));
}

// encodeDraftSave wraps the player's composition JSON. The string is opaque on the
// wire — the gateway forwards it verbatim and only the client reads {rack_order, board_tiles}.
export function encodeDraftSave(gameId: string, json: string): Uint8Array {
  const b = new Builder(256);
  const gid = b.createString(gameId);
  const j = b.createString(json);
  fb.DraftRequest.startDraftRequest(b);
  fb.DraftRequest.addGameId(b, gid);
  fb.DraftRequest.addJson(b, j);
  return finish(b, fb.DraftRequest.endDraftRequest(b));
}

export function encodeSubmitPlay(
  gameId: string,
  tiles: PlacedTile[],
  variant: Variant,
): Uint8Array {
  const b = new Builder(256);
  const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
  const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
  const gid = b.createString(gameId);
  fb.SubmitPlayRequest.startSubmitPlayRequest(b);
  fb.SubmitPlayRequest.addGameId(b, gid);
  fb.SubmitPlayRequest.addTiles(b, vec);
  return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
}

export function encodeEval(gameId: string, tiles: PlacedTile[], variant: Variant): Uint8Array {
  const b = new Builder(256);
  const tileOffs = tiles.map((t) => buildPlayTile(b, t, variant));
  const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
  const gid = b.createString(gameId);
  fb.EvalRequest.startEvalRequest(b);
  fb.EvalRequest.addGameId(b, gid);
  fb.EvalRequest.addTiles(b, vec);
  return finish(b, fb.EvalRequest.endEvalRequest(b));
}

export function encodeExchange(gameId: string, tiles: string[], variant: Variant): Uint8Array {
  const b = new Builder(128);
  const vec = fb.ExchangeRequest.createTilesVector(
    b,
    tiles.map((l) => indexForLetter(variant, l)),
  );
  const gid = b.createString(gameId);
  fb.ExchangeRequest.startExchangeRequest(b);
  fb.ExchangeRequest.addGameId(b, gid);
  fb.ExchangeRequest.addTiles(b, vec);
  return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
}

export function encodeCheckWord(gameId: string, word: string, variant: Variant): Uint8Array {
  const b = new Builder(128);
  const vec = fb.CheckWordRequest.createWordVector(
    b,
    Array.from(word).map((ch) => indexForLetter(variant, ch)),
  );
  const gid = b.createString(gameId);
  fb.CheckWordRequest.startCheckWordRequest(b);
  fb.CheckWordRequest.addGameId(b, gid);
  fb.CheckWordRequest.addWord(b, vec);
  return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
}

export function encodeComplaint(gameId: string, word: string, note: string): Uint8Array {
  const b = new Builder(256);
  const gid = b.createString(gameId);
  const w = b.createString(word);
  const n = b.createString(note);
  fb.ComplaintRequest.startComplaintRequest(b);
  fb.ComplaintRequest.addGameId(b, gid);
  fb.ComplaintRequest.addWord(b, w);
  fb.ComplaintRequest.addNote(b, n);
  return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
}

export function encodeEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Uint8Array {
  const b = new Builder(64);
  const v = b.createString(variant);
  fb.EnqueueRequest.startEnqueueRequest(b);
  fb.EnqueueRequest.addVariant(b, v);
  fb.EnqueueRequest.addMultipleWordsPerTurn(b, multipleWords);
  fb.EnqueueRequest.addVsAi(b, vsAi);
  return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
}

export function encodeChatPost(gameId: string, body: string): Uint8Array {
  const b = new Builder(128);
  const gid = b.createString(gameId);
  const bd = b.createString(body);
  fb.ChatPostRequest.startChatPostRequest(b);
  fb.ChatPostRequest.addGameId(b, gid);
  fb.ChatPostRequest.addBody(b, bd);
  return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}

export function encodeTelegramLogin(
  initData: string,
  browserTz: string,
  subtype: string,
): Uint8Array {
  const b = new Builder(512);
  const d = b.createString(initData);
  const tz = b.createString(browserTz);
  const st = b.createString(subtype);
  fb.TelegramLoginRequest.startTelegramLoginRequest(b);
  fb.TelegramLoginRequest.addInitData(b, d);
  fb.TelegramLoginRequest.addBrowserTz(b, tz);
  fb.TelegramLoginRequest.addSubtype(b, st);
  return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
}

export function encodeVKLogin(params: string, browserTz: string, displayName: string): Uint8Array {
  const b = new Builder(512);
  const p = b.createString(params);
  const tz = b.createString(browserTz);
  const dn = b.createString(displayName);
  fb.VKLoginRequest.startVKLoginRequest(b);
  fb.VKLoginRequest.addParams(b, p);
  fb.VKLoginRequest.addBrowserTz(b, tz);
  fb.VKLoginRequest.addDisplayName(b, dn);
  return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
}

export function encodeGuestLogin(
  locale: string,
  browserTz: string,
  subtype: string,
): Uint8Array {
  const b = new Builder(64);
  const l = b.createString(locale);
  const tz = b.createString(browserTz);
  const st = b.createString(subtype);
  fb.GuestLoginRequest.startGuestLoginRequest(b);
  fb.GuestLoginRequest.addLocale(b, l);
  fb.GuestLoginRequest.addBrowserTz(b, tz);
  fb.GuestLoginRequest.addSubtype(b, st);
  return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}

export function encodeEmailRequest(
  email: string,
  browserTz: string,
  language: string,
  pwa: boolean,
): Uint8Array {
  const b = new Builder(128);
  const e = b.createString(email);
  const tz = b.createString(browserTz);
  const l = b.createString(language);
  fb.EmailRequestRequest.startEmailRequestRequest(b);
  fb.EmailRequestRequest.addEmail(b, e);
  fb.EmailRequestRequest.addBrowserTz(b, tz);
  fb.EmailRequestRequest.addLanguage(b, l);
  fb.EmailRequestRequest.addPwa(b, pwa);
  return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}

export function encodeEmailLogin(email: string, code: string, subtype: string): Uint8Array {
  const b = new Builder(128);
  const e = b.createString(email);
  const c = b.createString(code);
  const st = b.createString(subtype);
  fb.EmailLoginRequest.startEmailLoginRequest(b);
  fb.EmailLoginRequest.addEmail(b, e);
  fb.EmailLoginRequest.addCode(b, c);
  fb.EmailLoginRequest.addSubtype(b, st);
  return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}

export function encodeEmailConfirmLink(token: string): Uint8Array {
  const b = new Builder(128);
  const t = b.createString(token);
  fb.EmailConfirmLinkRequest.startEmailConfirmLinkRequest(b);
  fb.EmailConfirmLinkRequest.addToken(b, t);
  return finish(b, fb.EmailConfirmLinkRequest.endEmailConfirmLinkRequest(b));
}

// --- response decoders ---

function s(v: string | null): string {
  return v ?? '';
}

function decodeTile(t: fb.TileRecord): Tile {
  // The wire keeps the move journal in the solver's lower case; the UI renders upper case.
  return { row: t.row(), col: t.col(), letter: s(t.letter()).toUpperCase(), blank: t.blank() };
}

function decodeSeat(v: fb.SeatView): Seat {
  return {
    seat: v.seat(),
    accountId: s(v.accountId()),
    displayName: s(v.displayName()),
    score: v.score(),
    hintsUsed: v.hintsUsed(),
    isWinner: v.isWinner(),
    deleted: v.deleted(),
  };
}

function decodeGameView(g: fb.GameView): GameView {
  const seats: Seat[] = [];
  for (let i = 0; i < g.seatsLength(); i++) {
    const sv = g.seats(i);
    if (sv) seats.push(decodeSeat(sv));
  }
  return {
    id: s(g.id()),
    variant: s(g.variant()) as Variant,
    dictVersion: s(g.dictVersion()),
    status: s(g.status()),
    players: g.players(),
    toMove: g.toMove(),
    turnTimeoutSecs: g.turnTimeoutSecs(),
    multipleWordsPerTurn: g.multipleWordsPerTurn(),
    noRepeatWords: g.noRepeatWords(),
    moveCount: g.moveCount(),
    endReason: s(g.endReason()),
    lastActivityUnix: Number(g.lastActivityUnix()),
    vsAi: g.vsAi(),
    unreadChat: g.unreadChat(),
    unreadMessages: g.unreadMessages(),
    kind: g.kind(),
    seats,
  };
}

function decodeMove(m: fb.MoveRecord): MoveRecord {
  const tiles: Tile[] = [];
  for (let i = 0; i < m.tilesLength(); i++) {
    const t = m.tiles(i);
    if (t) tiles.push(decodeTile(t));
  }
  const words: string[] = [];
  for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)).toUpperCase());
  return {
    player: m.player(),
    action: s(m.action()),
    dir: s(m.dir()),
    mainRow: m.mainRow(),
    mainCol: m.mainCol(),
    tiles,
    words,
    count: m.count(),
    score: m.score(),
    total: m.total(),
  };
}

function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
  return {
    id: s(m.id()),
    gameId: s(m.gameId()),
    senderId: s(m.senderId()),
    kind: s(m.kind()),
    body: s(m.body()),
    createdAtUnix: Number(m.createdAtUnix()),
  };
}

// sessionFromTable projects a FlatBuffers Session table (a root or a nested one) to
// the Session model.
function sessionFromTable(t: fb.Session): Session {
  return {
    token: s(t.token()),
    userId: s(t.userId()),
    isGuest: t.isGuest(),
    displayName: s(t.displayName()),
  };
}

export function decodeSession(buf: Uint8Array): Session {
  return sessionFromTable(fb.Session.getRootAsSession(new ByteBuffer(buf)));
}

// encodeWalletBuy wraps the product id for a chip spend (POST /user/wallet/buy).
export function encodeWalletBuy(productId: string): Uint8Array {
  const b = new Builder(64);
  const pid = b.createString(productId);
  fb.WalletBuyRequest.startWalletBuyRequest(b);
  fb.WalletBuyRequest.addProductId(b, pid);
  return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b));
}

// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order).
export function encodeWalletOrder(productId: string): Uint8Array {
  const b = new Builder(64);
  const pid = b.createString(productId);
  fb.WalletOrderRequest.startWalletOrderRequest(b);
  fb.WalletOrderRequest.addProductId(b, pid);
  return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b));
}

// decodeWallet reads the wallet payload: the context-visible chip segments and the
// context-applicable benefits.
export function decodeWallet(buf: Uint8Array): Wallet {
  const w = fb.Wallet.getRootAsWallet(new ByteBuffer(buf));
  const segments: WalletSegment[] = [];
  for (let i = 0; i < w.segmentsLength(); i++) {
    const seg = w.segments(i);
    if (!seg) continue;
    segments.push({ source: s(seg.source()), chips: seg.chips(), spendable: seg.spendable() });
  }
  return {
    segments,
    adsForever: w.adsForever(),
    adsPaidUntilMs: Number(w.adsPaidUntilMs()),
    hints: w.hints(),
    rewardChips: w.rewardChips(),
  };
}

// encodeWalletReward wraps a watched rewarded video for crediting (POST /user/wallet/reward): the
// per-view idempotency nonce.
export function encodeWalletReward(nonce: string): Uint8Array {
  const b = new Builder(64);
  const n = b.createString(nonce);
  fb.WalletRewardRequest.startWalletRewardRequest(b);
  fb.WalletRewardRequest.addNonce(b, n);
  return finish(b, fb.WalletRewardRequest.endWalletRewardRequest(b));
}

// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
  const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
  return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) };
}

// decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced
// value or a chip pack priced in the context's payment method, with its atom composition.
export function decodeCatalog(buf: Uint8Array): Catalog {
  const c = fb.Catalog.getRootAsCatalog(new ByteBuffer(buf));
  const products: CatalogProduct[] = [];
  for (let i = 0; i < c.productsLength(); i++) {
    const p = c.products(i);
    if (!p) continue;
    const atoms: CatalogAtom[] = [];
    for (let j = 0; j < p.atomsLength(); j++) {
      const a = p.atoms(j);
      if (!a) continue;
      atoms.push({ atomType: s(a.atomType()), quantity: a.quantity() });
    }
    products.push({
      kind: s(p.kind()) === 'pack' ? 'pack' : 'value',
      productId: s(p.productId()),
      title: s(p.title()),
      chips: p.chips(),
      moneyAmount: Number(p.moneyAmount()),
      moneyCurrency: s(p.moneyCurrency()),
      atoms,
    });
  }
  return { products };
}

export function decodeProfile(buf: Uint8Array): Profile {
  const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
  return {
    userId: s(p.userId()),
    displayName: s(p.displayName()),
    preferredLanguage: s(p.preferredLanguage()),
    timeZone: s(p.timeZone()),
    awayStart: s(p.awayStart()),
    awayEnd: s(p.awayEnd()),
    hintBalance: p.hintBalance(),
    blockChat: p.blockChat(),
    blockFriendRequests: p.blockFriendRequests(),
    isGuest: p.isGuest(),
    notificationsInAppOnly: p.notificationsInAppOnly(),
    variantPreferences: decodeVariantPreferences(p),
    banner: decodeBanner(p),
    ads: decodeAds(p),
    email: s(p.email()),
    telegramLinked: p.telegramLinked(),
    vkLinked: p.vkLinked(),
    dictVersions: decodeDictVersions(p),
    gameLimits: decodeGameLimits(p),
  };
}

// decodeGameLimits projects the caller's tier active-game caps (per kind, -1 = unlimited), or
// undefined when absent (an old backend); the New-Game screen then applies no lock.
function decodeGameLimits(p: fb.Profile): GameLimits | undefined {
  const g = p.gameLimits();
  if (!g) return undefined;
  return { vsAi: g.vsAi(), random: g.random(), friends: g.friends() };
}

// decodeDictVersions reads the profile's per-variant current dictionary versions into a
// variant-keyed map, so the offline preloader can look up the version for each enabled
// variant. An entry with an unknown variant or empty version is skipped.
function decodeDictVersions(p: fb.Profile): Partial<Record<Variant, string>> {
  const out: Partial<Record<Variant, string>> = {};
  for (let i = 0; i < p.dictVersionsLength(); i++) {
    const dv = p.dictVersions(i);
    if (!dv) continue;
    const variant = s(dv.variant()) as Variant;
    const version = s(dv.version());
    if (variant && version) out[variant] = version;
  }
  return out;
}

// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
// the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] {
  const out: Variant[] = [];
  for (let i = 0; i < p.variantPreferencesLength(); i++) out.push(s(p.variantPreferences(i)) as Variant);
  return out;
}

// bannerTriple builds a colour override from the three wire fields, or null when the
// set is absent (any field empty/missing) — the strip then keeps the neutral tokens.
function bannerTriple(bg: string | null, fg: string | null, link: string | null): ColorTriple | null {
  return bg && fg && link ? { bg, fg, link } : null;
}

// decodeAds projects the optional post-move interstitial config of a Profile, or undefined when
// absent (an old backend); the client then shows no interstitial.
function decodeAds(p: fb.Profile): AdsConfig | undefined {
  const a = p.ads();
  if (!a) return undefined;
  return {
    cooldownGlobalS: a.cooldownGlobalS(),
    cooldownVsAiS: a.cooldownVsAiS(),
    cooldownHintS: a.cooldownHintS(),
    suppressed: a.suppressed(),
  };
}

// decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
  const b = p.banner();
  if (!b) return undefined;
  const campaigns: BannerCampaign[] = [];
  for (let i = 0; i < b.campaignsLength(); i++) {
    const c = b.campaigns(i);
    if (!c) continue;
    const messages: string[] = [];
    for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
    campaigns.push({
      weight: c.weight(),
      messages,
      overrideAll: bannerTriple(c.overrideBg(), c.overrideFg(), c.overrideLink()),
      overrideDark: bannerTriple(c.overrideBgDark(), c.overrideFgDark(), c.overrideLinkDark()),
    });
  }
  return {
    campaigns,
    timings: {
      holdMs: b.holdMs(),
      edgePauseMs: b.edgePauseMs(),
      scrollPxPerSec: b.scrollPxPerSec(),
      fadeOutMs: b.fadeOutMs(),
      gapMs: b.gapMs(),
      fadeInMs: b.fadeInMs(),
    },
  };
}

export function decodeBlockStatus(buf: Uint8Array): BlockStatus {
  const b = fb.BlockStatus.getRootAsBlockStatus(new ByteBuffer(buf));
  return {
    blocked: b.blocked(),
    permanent: b.permanent(),
    until: s(b.until()),
    reason: s(b.reason()),
  };
}

// decodeStateViewTable projects a StateView table (a root or one nested in an event) to the
// model. It caches the alphabet when present (a per-variant cache miss) and decodes the index
// rack to display letters with it.
function decodeStateViewTable(v: fb.StateView): StateView {
  const g = v.game();
  const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
  if (v.alphabetLength() > 0) {
    const entries: AlphabetEntryWire[] = [];
    for (let i = 0; i < v.alphabetLength(); i++) {
      const e = v.alphabet(i);
      if (e) entries.push({ index: e.index(), letter: s(e.letter()), value: e.value() });
    }
    setAlphabet(variant, entries);
  }
  const rack: string[] = [];
  for (let i = 0; i < v.rackLength(); i++) rack.push(letterForIndex(variant, v.rack(i) ?? 0));
  return {
    game: g ? decodeGameView(g) : emptyGame(),
    seat: v.seat(),
    rack,
    bagLen: v.bagLen(),
    hintsRemaining: v.hintsRemaining(),
    hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
  };
}

export function decodeStateView(buf: Uint8Array): StateView {
  return decodeStateViewTable(fb.StateView.getRootAsStateView(new ByteBuffer(buf)));
}

export function decodeMoveResult(buf: Uint8Array): MoveResult {
  const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
  const m = r.move();
  const g = r.game();
  // The actor's refilled rack rides back as alphabet indices; decode it with the game's variant.
  const variant = (g ? s(g.variant()) : 'scrabble_en') as Variant;
  const rack: string[] = [];
  for (let i = 0; i < r.rackLength(); i++) rack.push(letterForIndex(variant, r.rack(i) ?? 0));
  return {
    move: m ? decodeMove(m) : emptyMove(),
    game: g ? decodeGameView(g) : emptyGame(),
    rack,
    bagLen: r.bagLen(),
  };
}

export function decodeHintResult(buf: Uint8Array): HintResult {
  const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
  const m = r.move();
  return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining(), walletBalance: r.walletBalance() };
}

export function decodeEvalResult(buf: Uint8Array): EvalResult {
  const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
  const words: string[] = [];
  for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
  return { legal: r.legal(), score: r.score(), words, dir: s(r.dir()) };
}

export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
  const r = fb.WordCheckResult.getRootAsWordCheckResult(new ByteBuffer(buf));
  return { word: s(r.word()), legal: r.legal() };
}

// decodeDraftView returns the player's stored composition JSON (empty when none is stored or
// for the save acknowledgement); the caller parses {rack_order, board_tiles}.
export function decodeDraftView(buf: Uint8Array): string {
  const v = fb.DraftView.getRootAsDraftView(new ByteBuffer(buf));
  return v.json() ?? '';
}

export function decodeHistory(buf: Uint8Array): History {
  const h = fb.History.getRootAsHistory(new ByteBuffer(buf));
  const moves: MoveRecord[] = [];
  for (let i = 0; i < h.movesLength(); i++) {
    const m = h.moves(i);
    if (m) moves.push(decodeMove(m));
  }
  return { gameId: s(h.gameId()), moves };
}

export function decodeGameList(buf: Uint8Array): GameList {
  const gl = fb.GameList.getRootAsGameList(new ByteBuffer(buf));
  const games: GameView[] = [];
  for (let i = 0; i < gl.gamesLength(); i++) {
    const g = gl.games(i);
    if (g) games.push(decodeGameView(g));
  }
  return { games, atGameLimit: gl.atGameLimit() };
}

export function decodeMatchResult(buf: Uint8Array): MatchResult {
  const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf));
  const g = m.game();
  // Enqueue always lands the caller in a game — an open game awaiting an opponent has
  // matched=false but still carries it — so decode the game whenever it is present.
  return { matched: m.matched(), game: g ? decodeGameView(g) : undefined };
}

export function decodeChatMessage(buf: Uint8Array): ChatMessage {
  return decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(new ByteBuffer(buf)));
}

export function decodeChatList(buf: Uint8Array): ChatMessage[] {
  const cl = fb.ChatList.getRootAsChatList(new ByteBuffer(buf));
  const out: ChatMessage[] = [];
  for (let i = 0; i < cl.messagesLength(); i++) {
    const m = cl.messages(i);
    if (m) out.push(decodeChatMsg(m));
  }
  return out;
}

export function encodeFeedbackSubmit(
  body: string,
  attachment: Uint8Array | null,
  attachmentName: string,
  channel: string,
  version: string,
  browserTz: string,
): Uint8Array {
  const b = new Builder(256);
  const bodyOff = b.createString(body);
  const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
  const nameOff = b.createString(attachmentName);
  const chOff = b.createString(channel);
  const verOff = b.createString(version);
  const tzOff = b.createString(browserTz);
  fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
  fb.FeedbackSubmitRequest.addBody(b, bodyOff);
  if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
  fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
  fb.FeedbackSubmitRequest.addChannel(b, chOff);
  fb.FeedbackSubmitRequest.addVersion(b, verOff);
  fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
  return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
}

export function decodeFeedbackState(buf: Uint8Array): FeedbackState {
  const st = fb.FeedbackState.getRootAsFeedbackState(new ByteBuffer(buf));
  const r = st.reply();
  return {
    canSend: st.canSend(),
    blockedReason: s(st.blockedReason()),
    reply: r ? { body: s(r.body()), repliedAtUnix: Number(r.repliedAtUnix()) } : null,
  };
}

export function decodeFeedbackUnread(buf: Uint8Array): boolean {
  return fb.FeedbackUnread.getRootAsFeedbackUnread(new ByteBuffer(buf)).replyUnread();
}

export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
  const bb = new ByteBuffer(payload);
  switch (kind) {
    case 'your_turn': {
      const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb);
      return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), opponentName: s(e.opponentName()), moveCount: e.moveCount() };
    }
    case 'opponent_moved': {
      const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb);
      const m = e.move();
      const g = e.game();
      return {
        kind: 'opponent_moved',
        gameId: s(e.gameId()),
        move: m ? decodeMove(m) : undefined,
        game: g ? decodeGameView(g) : undefined,
        bagLen: e.bagLen(),
      };
    }
    case 'game_over': {
      const e = fb.GameOverEvent.getRootAsGameOverEvent(bb);
      const g = e.game();
      return {
        kind: 'game_over',
        gameId: s(e.gameId()),
        result: s(e.result()),
        scoreLine: s(e.scoreLine()),
        game: g ? decodeGameView(g) : undefined,
      };
    }
    case 'chat_message':
      return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
    case 'nudge': {
      const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
      return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()), senderName: s(e.senderName()) };
    }
    case 'match_found': {
      const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
      const st = e.state();
      return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
    }
    case 'opponent_joined': {
      // opponent_joined reuses the match_found payload layout (game id + the recipient's state).
      const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
      const st = e.state();
      return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
    }
    case 'notify': {
      const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
      const acc = e.account();
      const inv = e.invitation();
      const st = e.state();
      return {
        kind: 'notify',
        sub: s(e.kind()),
        account: acc ? decodeAccountRef(acc) : undefined,
        invitation: inv ? decodeInvitationTable(inv) : undefined,
        state: st ? decodeStateViewTable(st) : undefined,
      };
    }
    case 'heartbeat':
      return { kind: 'heartbeat' };
    default:
      return null;
  }
}

// --- social encoders ---

export function encodeTarget(accountId: string, gameId?: string): Uint8Array {
  const b = new Builder(64);
  const id = b.createString(accountId);
  // game_id is set only by an in-game block, so a disguised-robot opponent is recorded
  // per-game; every other target path omits it.
  const gid = gameId ? b.createString(gameId) : 0;
  fb.TargetRequest.startTargetRequest(b);
  fb.TargetRequest.addAccountId(b, id);
  if (gid) fb.TargetRequest.addGameId(b, gid);
  return finish(b, fb.TargetRequest.endTargetRequest(b));
}

export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array {
  const b = new Builder(64);
  const id = b.createString(requesterId);
  fb.FriendRespondRequest.startFriendRespondRequest(b);
  fb.FriendRespondRequest.addRequesterId(b, id);
  fb.FriendRespondRequest.addAccept(b, accept);
  return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b));
}

export function encodeRedeemCode(code: string): Uint8Array {
  const b = new Builder(32);
  const c = b.createString(code);
  fb.RedeemCodeRequest.startRedeemCodeRequest(b);
  fb.RedeemCodeRequest.addCode(b, c);
  return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b));
}

export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array {
  const b = new Builder(256);
  const idOffs = inviteeIds.map((id) => b.createString(id));
  const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs);
  const variant = b.createString(st.variant);
  const dropout = b.createString(st.dropoutTiles);
  fb.CreateInvitationRequest.startCreateInvitationRequest(b);
  fb.CreateInvitationRequest.addInviteeIds(b, ids);
  fb.CreateInvitationRequest.addVariant(b, variant);
  fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs);
  fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed);
  fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer);
  fb.CreateInvitationRequest.addDropoutTiles(b, dropout);
  fb.CreateInvitationRequest.addMultipleWordsPerTurn(b, st.multipleWordsPerTurn);
  return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b));
}

export function encodeInvitationAction(invitationId: string): Uint8Array {
  const b = new Builder(64);
  const id = b.createString(invitationId);
  fb.InvitationActionRequest.startInvitationActionRequest(b);
  fb.InvitationActionRequest.addInvitationId(b, id);
  return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b));
}

export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
  const b = new Builder(256);
  const name = b.createString(p.displayName);
  const lang = b.createString(p.preferredLanguage);
  const tz = b.createString(p.timeZone);
  const as = b.createString(p.awayStart);
  const ae = b.createString(p.awayEnd);
  const prefs = fb.UpdateProfileRequest.createVariantPreferencesVector(
    b,
    p.variantPreferences.map((v) => b.createString(v)),
  );
  fb.UpdateProfileRequest.startUpdateProfileRequest(b);
  fb.UpdateProfileRequest.addDisplayName(b, name);
  fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
  fb.UpdateProfileRequest.addTimeZone(b, tz);
  fb.UpdateProfileRequest.addAwayStart(b, as);
  fb.UpdateProfileRequest.addAwayEnd(b, ae);
  fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
  fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
  fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
  fb.UpdateProfileRequest.addVariantPreferences(b, prefs);
  return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}

// --- account linking & merge ---

export function encodeLinkEmailRequest(email: string): Uint8Array {
  const b = new Builder(128);
  const e = b.createString(email);
  fb.LinkEmailRequest.startLinkEmailRequest(b);
  fb.LinkEmailRequest.addEmail(b, e);
  return finish(b, fb.LinkEmailRequest.endLinkEmailRequest(b));
}

export function encodeLinkEmailConfirm(email: string, code: string): Uint8Array {
  const b = new Builder(128);
  const e = b.createString(email);
  const c = b.createString(code);
  fb.LinkEmailConfirm.startLinkEmailConfirm(b);
  fb.LinkEmailConfirm.addEmail(b, e);
  fb.LinkEmailConfirm.addCode(b, c);
  return finish(b, fb.LinkEmailConfirm.endLinkEmailConfirm(b));
}

export function encodeLinkTelegram(data: string): Uint8Array {
  const b = new Builder(256);
  const d = b.createString(data);
  fb.LinkTelegramRequest.startLinkTelegramRequest(b);
  fb.LinkTelegramRequest.addData(b, d);
  return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
}

export function encodeLinkUnlink(kind: string): Uint8Array {
  const b = new Builder(64);
  const k = b.createString(kind);
  fb.LinkUnlinkRequest.startLinkUnlinkRequest(b);
  fb.LinkUnlinkRequest.addKind(b, k);
  return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
}

export function encodeLinkVK(code: string, deviceId: string, codeVerifier: string): Uint8Array {
  const b = new Builder(256);
  const c = b.createString(code);
  const d = b.createString(deviceId);
  const v = b.createString(codeVerifier);
  fb.LinkVKRequest.startLinkVKRequest(b);
  fb.LinkVKRequest.addCode(b, c);
  fb.LinkVKRequest.addDeviceId(b, d);
  fb.LinkVKRequest.addCodeVerifier(b, v);
  return finish(b, fb.LinkVKRequest.endLinkVKRequest(b));
}

export function encodeAccountDeleteConfirm(code: string, phrase: string): Uint8Array {
  const b = new Builder(64);
  const c = b.createString(code);
  const p = b.createString(phrase);
  fb.AccountDeleteConfirm.startAccountDeleteConfirm(b);
  fb.AccountDeleteConfirm.addCode(b, c);
  fb.AccountDeleteConfirm.addPhrase(b, p);
  return finish(b, fb.AccountDeleteConfirm.endAccountDeleteConfirm(b));
}

export function decodeDeleteRequestResult(buf: Uint8Array): { method: 'email' | 'phrase' } {
  const r = fb.AccountDeleteRequestResult.getRootAsAccountDeleteRequestResult(new ByteBuffer(buf));
  return { method: (s(r.method()) || 'phrase') as 'email' | 'phrase' };
}

export function decodeLinkResult(buf: Uint8Array): LinkResult {
  const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
  const sess = r.session();
  return {
    status: (s(r.status()) || 'linked') as LinkResult['status'],
    secondaryUserId: s(r.secondaryUserId()),
    secondaryDisplayName: s(r.secondaryDisplayName()),
    secondaryGames: r.secondaryGames(),
    secondaryFriends: r.secondaryFriends(),
    session: sess ? sessionFromTable(sess) : null,
  };
}

export function decodeConfirmLinkResult(buf: Uint8Array): ConfirmLinkResult {
  const r = fb.EmailConfirmLinkResult.getRootAsEmailConfirmLinkResult(new ByteBuffer(buf));
  const sess = r.session();
  return {
    purpose: (s(r.purpose()) || 'link') as ConfirmLinkResult['purpose'],
    status: (s(r.status()) || 'confirmed') as ConfirmLinkResult['status'],
    session: sess ? sessionFromTable(sess) : null,
  };
}

// --- social decoders ---

function decodeAccountRef(r: fb.AccountRef): AccountRef {
  return { accountId: s(r.accountId()), displayName: s(r.displayName()) };
}

export function decodeFriendList(buf: Uint8Array): AccountRef[] {
  const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf));
  const out: AccountRef[] = [];
  for (let i = 0; i < l.friendsLength(); i++) {
    const r = l.friends(i);
    if (r) out.push(decodeAccountRef(r));
  }
  return out;
}

export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
  const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf));
  const out: AccountRef[] = [];
  for (let i = 0; i < l.requestsLength(); i++) {
    const r = l.requests(i);
    if (r) out.push(decodeAccountRef(r));
  }
  return out;
}

export function decodeOutgoingList(buf: Uint8Array): OutgoingList {
  const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf));
  const requests: AccountRef[] = [];
  for (let i = 0; i < l.requestsLength(); i++) {
    const r = l.requests(i);
    if (r) requests.push(decodeAccountRef(r));
  }
  const robots: RobotFriendRequestEntry[] = [];
  for (let i = 0; i < l.robotsLength(); i++) {
    const r = l.robots(i);
    if (r)
      robots.push({
        id: r.id() ?? '',
        displayName: r.displayName() ?? '',
        gameId: r.gameId() ?? '',
        seat: r.seat(),
      });
  }
  return { requests, robots };
}

export function decodeBlockList(buf: Uint8Array): BlockList {
  const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf));
  const blocked: AccountRef[] = [];
  for (let i = 0; i < l.blockedLength(); i++) {
    const r = l.blocked(i);
    if (r) blocked.push(decodeAccountRef(r));
  }
  const robots: RobotBlockEntry[] = [];
  for (let i = 0; i < l.robotsLength(); i++) {
    const r = l.robots(i);
    if (r)
      robots.push({
        id: r.id() ?? '',
        displayName: r.displayName() ?? '',
        gameId: r.gameId() ?? '',
        seat: r.seat(),
      });
  }
  return { blocked, robots };
}

export function decodeFriendCode(buf: Uint8Array): FriendCode {
  const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf));
  return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) };
}

export function decodeRedeemResult(buf: Uint8Array): AccountRef {
  const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf));
  const f = r.friend();
  return f ? decodeAccountRef(f) : { accountId: '', displayName: '' };
}

export function decodeStats(buf: Uint8Array): Stats {
  const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf));
  const bestMoves: BestMove[] = [];
  for (let i = 0; i < v.bestMovesLength(); i++) {
    const m = v.bestMoves(i);
    if (!m) continue;
    const word: BestMoveTile[] = [];
    for (let j = 0; j < m.wordLength(); j++) {
      const t = m.word(j);
      if (t) word.push({ letter: s(t.letter()), value: t.value(), blank: t.blank() });
    }
    bestMoves.push({ variant: s(m.variant()) as Variant, score: m.score(), word });
  }
  return {
    wins: v.wins(),
    losses: v.losses(),
    draws: v.draws(),
    maxGamePoints: v.maxGamePoints(),
    maxWordPoints: v.maxWordPoints(),
    moves: v.moves(),
    hintsUsed: v.hintsUsed(),
    bestMoves,
  };
}

function decodeInvitationTable(i: fb.Invitation): Invitation {
  const inviter = i.inviter();
  const invitees: InvitationInvitee[] = [];
  for (let k = 0; k < i.inviteesLength(); k++) {
    const iv = i.invitees(k);
    if (iv) {
      invitees.push({
        accountId: s(iv.accountId()),
        displayName: s(iv.displayName()),
        seat: iv.seat(),
        response: s(iv.response()),
      });
    }
  }
  return {
    id: s(i.id()),
    inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' },
    invitees,
    variant: s(i.variant()) as Variant,
    turnTimeoutSecs: i.turnTimeoutSecs(),
    hintsAllowed: i.hintsAllowed(),
    hintsPerPlayer: i.hintsPerPlayer(),
    multipleWordsPerTurn: i.multipleWordsPerTurn(),
    dropoutTiles: s(i.dropoutTiles()),
    status: s(i.status()),
    gameId: s(i.gameId()),
    expiresAtUnix: Number(i.expiresAtUnix()),
  };
}

export function decodeInvitation(buf: Uint8Array): Invitation {
  return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf)));
}

export function decodeInvitationList(buf: Uint8Array): Invitation[] {
  const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf));
  const out: Invitation[] = [];
  for (let i = 0; i < l.invitationsLength(); i++) {
    const inv = l.invitations(i);
    if (inv) out.push(decodeInvitationTable(inv));
  }
  return out;
}

export function decodeGcg(buf: Uint8Array): GcgExport {
  const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf));
  return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) };
}

export function encodeExportUrlRequest(
  gameId: string,
  kind: 'png' | 'gcg',
  dateLocale: string,
  actionLabels: string[],
  timeZone: string,
): Uint8Array {
  const b = new Builder(256);
  const gid = b.createString(gameId);
  const k = b.createString(kind);
  const dl = b.createString(dateLocale);
  const tz = b.createString(timeZone);
  const labels = fb.ExportUrlRequest.createActionLabelsVector(
    b,
    actionLabels.map((l) => b.createString(l)),
  );
  fb.ExportUrlRequest.startExportUrlRequest(b);
  fb.ExportUrlRequest.addGameId(b, gid);
  fb.ExportUrlRequest.addKind(b, k);
  fb.ExportUrlRequest.addDateLocale(b, dl);
  fb.ExportUrlRequest.addActionLabels(b, labels);
  fb.ExportUrlRequest.addTimeZone(b, tz);
  return finish(b, fb.ExportUrlRequest.endExportUrlRequest(b));
}

export function decodeExportUrl(buf: Uint8Array): ExportUrl {
  const e = fb.ExportUrl.getRootAsExportUrl(new ByteBuffer(buf));
  return { path: s(e.path()), filename: s(e.filename()) };
}

function emptyGame(): GameView {
  return {
    id: '',
    variant: 'scrabble_en',
    dictVersion: '',
    status: '',
    players: 0,
    toMove: 0,
    turnTimeoutSecs: 0,
    multipleWordsPerTurn: true,
    noRepeatWords: false,
    moveCount: 0,
    endReason: '',
    lastActivityUnix: 0,
    vsAi: false,
    unreadChat: false,
    unreadMessages: false,
    kind: 0,
    seats: [],
  };
}

function emptyMove(): MoveRecord {
  return { player: 0, action: '', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}
