import { Builder, ByteBuffer } from 'flatbuffers';
import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { BLANK_INDEX, setAlphabet } from './alphabet';
import {
  decodeBlockStatus,
  decodeDraftView,
  decodeEvent,
  decodeExportUrl,
  decodeFriendList,
  decodeGameList,
  decodeInvitation,
  decodeLinkResult,
  decodeMatchResult,
  decodeOutgoingList,
  decodeProfile,
  decodeWallet,
  encodeWalletReward,
  decodeCatalog,
  encodeWalletBuy,
  encodeWalletOrder,
  decodeWalletOrder,
  decodeSession,
  decodeFeedbackState,
  decodeFeedbackUnread,
  decodeStateView,
  decodeStats,
  encodeCheckWord,
  encodeEmailRequest,
  encodeEmailConfirmLink,
  encodeFeedbackSubmit,
  encodeDraftSave,
  encodeEnqueue,
  encodeExchange,
  encodeExportUrlRequest,
  encodeAccountDeleteConfirm,
  decodeDeleteRequestResult,
  encodeGuestLogin,
  encodeLinkUnlink,
  encodeLinkVK,
  encodeStateRequest,
  encodeSubmitPlay,
  encodeTarget,
  encodeTelegramLogin,
  encodeUpdateProfile,
  encodeVKLogin,
} from './codec';

describe('codec', () => {
  it('round-trips the wallet buy request and the wallet view', () => {
    // buy request: product id survives the wire
    const req = fb.WalletBuyRequest.getRootAsWalletBuyRequest(new ByteBuffer(encodeWalletBuy('prod-42')));
    expect(req.productId()).toBe('prod-42');

    // wallet response: build a Wallet payload and decode it, checking the segment vector,
    // the spendable flag and the benefit fields (the long ms field decodes to a number).
    const b = new Builder(128);
    const src = b.createString('direct');
    fb.WalletSegment.startWalletSegment(b);
    fb.WalletSegment.addSource(b, src);
    fb.WalletSegment.addChips(b, 120);
    fb.WalletSegment.addSpendable(b, true);
    const seg = fb.WalletSegment.endWalletSegment(b);
    const segs = fb.Wallet.createSegmentsVector(b, [seg]);
    fb.Wallet.startWallet(b);
    fb.Wallet.addSegments(b, segs);
    fb.Wallet.addAdsForever(b, false);
    fb.Wallet.addAdsPaidUntilMs(b, BigInt(1_700_000_000_000));
    fb.Wallet.addHints(b, 5);
    fb.Wallet.addRewardChips(b, 7);
    b.finish(fb.Wallet.endWallet(b));

    const w = decodeWallet(b.asUint8Array());
    expect(w.segments).toEqual([{ source: 'direct', chips: 120, spendable: true }]);
    expect(w.adsForever).toBe(false);
    expect(w.adsPaidUntilMs).toBe(1_700_000_000_000);
    expect(w.hints).toBe(5);
    expect(w.rewardChips).toBe(7);
  });

  it('round-trips the wallet reward request (nonce)', () => {
    const req = fb.WalletRewardRequest.getRootAsWalletRewardRequest(
      new ByteBuffer(encodeWalletReward('nonce-9')),
    );
    expect(req.nonce()).toBe('nonce-9');
  });

  it('round-trips the wallet order request and response', () => {
    // order request: pack id survives the wire
    const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7')));
    expect(req.productId()).toBe('pack-7');

    // order response: the created id and the provider launch URL decode back
    const b = new Builder(64);
    const oid = b.createString('order-123');
    const url = b.createString('https://pay.example/abc');
    fb.WalletOrderResponse.startWalletOrderResponse(b);
    fb.WalletOrderResponse.addOrderId(b, oid);
    fb.WalletOrderResponse.addRedirectUrl(b, url);
    b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b));

    const order = decodeWalletOrder(b.asUint8Array());
    expect(order.orderId).toBe('order-123');
    expect(order.redirectUrl).toBe('https://pay.example/abc');
  });

  it('round-trips the catalog view (value + pack)', () => {
    const b = new Builder(256);

    // a chip-priced value with one atom
    const vKind = b.createString('value');
    const vId = b.createString('val-1');
    const vTitle = b.createString('50 hints');
    const aType = b.createString('hints');
    fb.CatalogAtom.startCatalogAtom(b);
    fb.CatalogAtom.addAtomType(b, aType);
    fb.CatalogAtom.addQuantity(b, 50);
    const atom = fb.CatalogAtom.endCatalogAtom(b);
    const vAtoms = fb.CatalogProduct.createAtomsVector(b, [atom]);
    fb.CatalogProduct.startCatalogProduct(b);
    fb.CatalogProduct.addKind(b, vKind);
    fb.CatalogProduct.addProductId(b, vId);
    fb.CatalogProduct.addTitle(b, vTitle);
    fb.CatalogProduct.addChips(b, 100);
    fb.CatalogProduct.addMoneyAmount(b, BigInt(0));
    fb.CatalogProduct.addAtoms(b, vAtoms);
    const value = fb.CatalogProduct.endCatalogProduct(b);

    // a RUB-priced chip pack (no atoms exercised)
    const pKind = b.createString('pack');
    const pId = b.createString('pack-1');
    const pTitle = b.createString('100 chips');
    const pCur = b.createString('RUB');
    fb.CatalogProduct.startCatalogProduct(b);
    fb.CatalogProduct.addKind(b, pKind);
    fb.CatalogProduct.addProductId(b, pId);
    fb.CatalogProduct.addTitle(b, pTitle);
    fb.CatalogProduct.addChips(b, 0);
    fb.CatalogProduct.addMoneyAmount(b, BigInt(14900));
    fb.CatalogProduct.addMoneyCurrency(b, pCur);
    const pack = fb.CatalogProduct.endCatalogProduct(b);

    const prods = fb.Catalog.createProductsVector(b, [value, pack]);
    fb.Catalog.startCatalog(b);
    fb.Catalog.addProducts(b, prods);
    b.finish(fb.Catalog.endCatalog(b));

    const c = decodeCatalog(b.asUint8Array());
    expect(c.products).toEqual([
      { kind: 'value', productId: 'val-1', title: '50 hints', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
      { kind: 'pack', productId: 'pack-1', title: '100 chips', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [] },
    ]);
  });

  it('round-trips the export-url request and response', () => {
    const req = fb.ExportUrlRequest.getRootAsExportUrlRequest(
      new ByteBuffer(
        encodeExportUrlRequest('g-1', 'png', 'ru-RU', ['пас', 'обмен', 'сдался', 'таймаут'], 'Europe/Moscow'),
      ),
    );
    expect(req.gameId()).toBe('g-1');
    expect(req.kind()).toBe('png');
    expect(req.dateLocale()).toBe('ru-RU');
    expect(req.timeZone()).toBe('Europe/Moscow');
    expect(req.actionLabelsLength()).toBe(4);
    expect(req.actionLabels(1)).toBe('обмен');

    const b = new Builder(128);
    const path = b.createString('/dl/g-1/png?e=1&s=x');
    const fn = b.createString('game-g-1.png');
    fb.ExportUrl.startExportUrl(b);
    fb.ExportUrl.addPath(b, path);
    fb.ExportUrl.addFilename(b, fn);
    b.finish(fb.ExportUrl.endExportUrl(b));
    const out = decodeExportUrl(b.asUint8Array());
    expect(out).toEqual({ path: '/dl/g-1/png?e=1&s=x', filename: 'game-g-1.png' });
  });

  it('encodes an enqueue request carrying the variant, word rule and vs_ai flag', () => {
    for (const vsAi of [false, true]) {
      const req = fb.EnqueueRequest.getRootAsEnqueueRequest(
        new ByteBuffer(encodeEnqueue('scrabble_ru', false, vsAi)),
      );
      expect(req.variant()).toBe('scrabble_ru');
      expect(req.multipleWordsPerTurn()).toBe(false);
      expect(req.vsAi()).toBe(vsAi);
    }
  });

  it('round-trips a draft save request and view', () => {
    const json = '{"rack_order":"1,0","board_tiles":[]}';
    const req = fb.DraftRequest.getRootAsDraftRequest(new ByteBuffer(encodeDraftSave('g1', json)));
    expect(req.gameId()).toBe('g1');
    expect(req.json()).toBe(json);

    const b = new Builder(64);
    const j = b.createString('{"x":1}');
    fb.DraftView.startDraftView(b);
    fb.DraftView.addJson(b, j);
    b.finish(fb.DraftView.endDraftView(b));
    expect(decodeDraftView(b.asUint8Array())).toBe('{"x":1}');
  });

  it('decodes a nudge event carrying the sender name', () => {
    const b = new Builder(64);
    const gid = b.createString('g1');
    const from = b.createString('u2');
    const name = b.createString('Ann');
    fb.NudgeEvent.startNudgeEvent(b);
    fb.NudgeEvent.addGameId(b, gid);
    fb.NudgeEvent.addFromUserId(b, from);
    fb.NudgeEvent.addSenderName(b, name);
    b.finish(fb.NudgeEvent.endNudgeEvent(b));
    expect(decodeEvent('nudge', b.asUint8Array())).toEqual({
      kind: 'nudge',
      gameId: 'g1',
      fromUserId: 'u2',
      senderName: 'Ann',
    });
  });

  it('carries the detected browser zone on every account-creating auth request', () => {
    const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
      new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00', 'ios')),
    );
    expect(tg.initData()).toBe('init-data-blob');
    expect(tg.browserTz()).toBe('+03:00');
    expect(tg.subtype()).toBe('ios');

    const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
      new ByteBuffer(encodeGuestLogin('ru', '-05:30', 'android')),
    );
    expect(guest.locale()).toBe('ru');
    expect(guest.browserTz()).toBe('-05:30');
    expect(guest.subtype()).toBe('android');

    const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
      new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00', 'en', true)),
    );
    expect(email.email()).toBe('a@example.com');
    expect(email.browserTz()).toBe('+00:00');
    expect(email.pwa()).toBe(true);
    expect(email.language()).toBe('en');

    const confirm = fb.EmailConfirmLinkRequest.getRootAsEmailConfirmLinkRequest(
      new ByteBuffer(encodeEmailConfirmLink('tok-abc')),
    );
    expect(confirm.token()).toBe('tok-abc');

    const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
      new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
    );
    expect(vk.params()).toBe('vk_user_id=494075&vk_ts=1&sign=abc');
    expect(vk.browserTz()).toBe('+03:00');
    expect(vk.displayName()).toBe('Иван Петров');
  });

  it('round-trips a feedback submit and decodes state + unread', () => {
    const att = new Uint8Array([1, 2, 3, 4]);
    const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
      new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios', 'v1.2.3', '+03:00')),
    );
    expect(req.body()).toBe('please fix');
    expect(req.attachmentName()).toBe('shot.png');
    expect(req.channel()).toBe('ios');
    expect(req.version()).toBe('v1.2.3');
    expect(req.browserTz()).toBe('+03:00');
    expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);

    // No attachment: the vector is empty.
    const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
      new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web', 'dev', '+00:00')),
    );
    expect(req2.body()).toBe('hi');
    expect(req2.version()).toBe('dev');
    expect(req2.browserTz()).toBe('+00:00');
    expect(req2.attachmentLength()).toBe(0);

    // State carrying a reply.
    const b = new Builder(128);
    const replyBody = b.createString('we are on it');
    fb.FeedbackReply.startFeedbackReply(b);
    fb.FeedbackReply.addBody(b, replyBody);
    fb.FeedbackReply.addRepliedAtUnix(b, BigInt(1700000000));
    const reply = fb.FeedbackReply.endFeedbackReply(b);
    const reason = b.createString('');
    fb.FeedbackState.startFeedbackState(b);
    fb.FeedbackState.addCanSend(b, true);
    fb.FeedbackState.addBlockedReason(b, reason);
    fb.FeedbackState.addReply(b, reply);
    b.finish(fb.FeedbackState.endFeedbackState(b));
    expect(decodeFeedbackState(b.asUint8Array())).toEqual({
      canSend: true,
      blockedReason: '',
      reply: { body: 'we are on it', repliedAtUnix: 1700000000 },
    });

    // State with no reply, blocked as pending.
    const b2 = new Builder(64);
    const reason2 = b2.createString('pending');
    fb.FeedbackState.startFeedbackState(b2);
    fb.FeedbackState.addCanSend(b2, false);
    fb.FeedbackState.addBlockedReason(b2, reason2);
    b2.finish(fb.FeedbackState.endFeedbackState(b2));
    expect(decodeFeedbackState(b2.asUint8Array())).toEqual({
      canSend: false,
      blockedReason: 'pending',
      reply: null,
    });

    // Unread badge flag.
    const b3 = new Builder(16);
    fb.FeedbackUnread.startFeedbackUnread(b3);
    fb.FeedbackUnread.addReplyUnread(b3, true);
    b3.finish(fb.FeedbackUnread.endFeedbackUnread(b3));
    expect(decodeFeedbackUnread(b3.asUint8Array())).toBe(true);
  });

  it('decodes a temporary BlockStatus with reason', () => {
    const b = new Builder(64);
    const until = b.createString('2026-07-01T12:00:00Z');
    const reason = b.createString('Спам');
    fb.BlockStatus.startBlockStatus(b);
    fb.BlockStatus.addBlocked(b, true);
    fb.BlockStatus.addPermanent(b, false);
    fb.BlockStatus.addUntil(b, until);
    fb.BlockStatus.addReason(b, reason);
    b.finish(fb.BlockStatus.endBlockStatus(b));
    expect(decodeBlockStatus(b.asUint8Array())).toEqual({
      blocked: true,
      permanent: false,
      until: '2026-07-01T12:00:00Z',
      reason: 'Спам',
    });
  });

  it('decodes a permanent BlockStatus with empty until/reason', () => {
    const b = new Builder(32);
    fb.BlockStatus.startBlockStatus(b);
    fb.BlockStatus.addBlocked(b, true);
    fb.BlockStatus.addPermanent(b, true);
    b.finish(fb.BlockStatus.endBlockStatus(b));
    expect(decodeBlockStatus(b.asUint8Array())).toEqual({
      blocked: true,
      permanent: true,
      until: '',
      reason: '',
    });
  });

  it('encodes a SubmitPlayRequest with alphabet indices', () => {
    setAlphabet('scrabble_en', [
      { index: 0, letter: 'a', value: 1 },
      { index: 1, letter: 'b', value: 3 },
    ]);
    // A placed blank carries its designated letter's index with the blank flag set.
    const buf = encodeSubmitPlay(
      'g1',
      [
        { row: 7, col: 7, letter: 'A', blank: false },
        { row: 7, col: 8, letter: 'B', blank: true },
      ],
      'scrabble_en',
    );
    const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
    expect(r.gameId()).toBe('g1');
    expect(r.tilesLength()).toBe(2);
    expect(r.tiles(0)?.letter()).toBe(0);
    expect(r.tiles(1)?.letter()).toBe(1);
    expect(r.tiles(1)?.blank()).toBe(true);
  });

  it('decodes a Session', () => {
    const b = new Builder(64);
    const token = b.createString('tok');
    const uid = b.createString('u1');
    const name = b.createString('Me');
    fb.Session.startSession(b);
    fb.Session.addToken(b, token);
    fb.Session.addUserId(b, uid);
    fb.Session.addIsGuest(b, true);
    fb.Session.addDisplayName(b, name);
    b.finish(fb.Session.endSession(b));
    expect(decodeSession(b.asUint8Array())).toEqual({
      token: 'tok',
      userId: 'u1',
      isGuest: true,
      displayName: 'Me',
    });
  });

  it('decodes a GameList including nested seat display names', () => {
    const b = new Builder(256);
    const aid = b.createString('a1');
    const dn = b.createString('Ann');
    fb.SeatView.startSeatView(b);
    fb.SeatView.addSeat(b, 1);
    fb.SeatView.addAccountId(b, aid);
    fb.SeatView.addScore(b, 13);
    fb.SeatView.addHintsUsed(b, 0);
    fb.SeatView.addIsWinner(b, false);
    fb.SeatView.addDisplayName(b, dn);
    fb.SeatView.addDeleted(b, true);
    const seat = fb.SeatView.endSeatView(b);
    const seats = fb.GameView.createSeatsVector(b, [seat]);
    const id = b.createString('g1');
    const variant = b.createString('scrabble_en');
    const dv = b.createString('v1');
    const status = b.createString('active');
    const er = b.createString('');
    fb.GameView.startGameView(b);
    fb.GameView.addId(b, id);
    fb.GameView.addVariant(b, variant);
    fb.GameView.addDictVersion(b, dv);
    fb.GameView.addStatus(b, status);
    fb.GameView.addPlayers(b, 2);
    fb.GameView.addToMove(b, 0);
    fb.GameView.addTurnTimeoutSecs(b, 86400);
    fb.GameView.addMoveCount(b, 4);
    fb.GameView.addEndReason(b, er);
    fb.GameView.addSeats(b, seats);
    fb.GameView.addLastActivityUnix(b, BigInt(1717000000));
    fb.GameView.addVsAi(b, true);
    fb.GameView.addUnreadChat(b, true);
    fb.GameView.addKind(b, 2);
    fb.GameView.addNoRepeatWords(b, true);
    const game = fb.GameView.endGameView(b);
    const games = fb.GameList.createGamesVector(b, [game]);
    fb.GameList.startGameList(b);
    fb.GameList.addGames(b, games);
    fb.GameList.addAtGameLimit(b, true);
    b.finish(fb.GameList.endGameList(b));

    const gl = decodeGameList(b.asUint8Array());
    expect(gl.games).toHaveLength(1);
    expect(gl.games[0].id).toBe('g1');
    expect(gl.games[0].seats[0].displayName).toBe('Ann');
    expect(gl.games[0].seats[0].score).toBe(13);
    // The deleted mark rides the seat: it hides the social controls aimed at that seat.
    expect(gl.games[0].seats[0].deleted).toBe(true);
    expect(gl.games[0].lastActivityUnix).toBe(1717000000);
    expect(gl.games[0].vsAi).toBe(true);
    expect(gl.games[0].unreadChat).toBe(true);
    expect(gl.games[0].kind).toBe(2);
    // The Erudit no-repeat rule is pinned per game and rides the view: the on-device move
    // preview needs it to score the way the server does.
    expect(gl.games[0].noRepeatWords).toBe(true);
    expect(gl.atGameLimit).toBe(true);
  });

  it('decodes an OutgoingRequestList of account refs plus per-game robot requests', () => {
    const b = new Builder(128);
    const id = b.createString('o-1');
    const dn = b.createString('Pat');
    fb.AccountRef.startAccountRef(b);
    fb.AccountRef.addAccountId(b, id);
    fb.AccountRef.addDisplayName(b, dn);
    const ref = fb.AccountRef.endAccountRef(b);
    const reqVec = fb.OutgoingRequestList.createRequestsVector(b, [ref]);
    const rid = b.createString('r-1');
    const rdn = b.createString('Robbie');
    const rgid = b.createString('g-1');
    const robot = fb.RobotFriendRef.createRobotFriendRef(b, rid, rdn, rgid, 1);
    const robVec = fb.OutgoingRequestList.createRobotsVector(b, [robot]);
    fb.OutgoingRequestList.startOutgoingRequestList(b);
    fb.OutgoingRequestList.addRequests(b, reqVec);
    fb.OutgoingRequestList.addRobots(b, robVec);
    b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b));
    expect(decodeOutgoingList(b.asUint8Array())).toEqual({
      requests: [{ accountId: 'o-1', displayName: 'Pat' }],
      robots: [{ id: 'r-1', displayName: 'Robbie', gameId: 'g-1', seat: 1 }],
    });
  });

  it('encodes a TargetRequest', () => {
    const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1')));
    expect(r.accountId()).toBe('a-1');
  });

  it('decodes a StatsView', () => {
    const b = new Builder(64);
    fb.StatsView.startStatsView(b);
    fb.StatsView.addWins(b, 7);
    fb.StatsView.addLosses(b, 4);
    fb.StatsView.addDraws(b, 1);
    fb.StatsView.addMaxGamePoints(b, 420);
    fb.StatsView.addMaxWordPoints(b, 90);
    fb.StatsView.addMoves(b, 248);
    fb.StatsView.addHintsUsed(b, 12);
    b.finish(fb.StatsView.endStatsView(b));
    expect(decodeStats(b.asUint8Array())).toEqual({
      wins: 7,
      losses: 4,
      draws: 1,
      maxGamePoints: 420,
      maxWordPoints: 90,
      moves: 248,
      hintsUsed: 12,
      bestMoves: [],
    });
  });

  it('decodes a StatsView carrying a per-variant best move with a blank tile', () => {
    const b = new Builder(256);
    // Word "ca" where the 'a' is a blank: it carries its letter but scores 0.
    const cLetter = b.createString('c');
    fb.BestMoveTile.startBestMoveTile(b);
    fb.BestMoveTile.addLetter(b, cLetter);
    fb.BestMoveTile.addValue(b, 3);
    fb.BestMoveTile.addBlank(b, false);
    const tileC = fb.BestMoveTile.endBestMoveTile(b);
    const aLetter = b.createString('a');
    fb.BestMoveTile.startBestMoveTile(b);
    fb.BestMoveTile.addLetter(b, aLetter);
    fb.BestMoveTile.addValue(b, 0);
    fb.BestMoveTile.addBlank(b, true);
    const tileA = fb.BestMoveTile.endBestMoveTile(b);
    const word = fb.BestMoveView.createWordVector(b, [tileC, tileA]);
    const variant = b.createString('scrabble_en');
    fb.BestMoveView.startBestMoveView(b);
    fb.BestMoveView.addVariant(b, variant);
    fb.BestMoveView.addScore(b, 90);
    fb.BestMoveView.addWord(b, word);
    const bm = fb.BestMoveView.endBestMoveView(b);
    const bestMoves = fb.StatsView.createBestMovesVector(b, [bm]);
    fb.StatsView.startStatsView(b);
    fb.StatsView.addWins(b, 7);
    fb.StatsView.addBestMoves(b, bestMoves);
    b.finish(fb.StatsView.endStatsView(b));
    expect(decodeStats(b.asUint8Array())).toEqual({
      wins: 7,
      losses: 0,
      draws: 0,
      maxGamePoints: 0,
      maxWordPoints: 0,
      moves: 0,
      hintsUsed: 0,
      bestMoves: [
        {
          variant: 'scrabble_en',
          score: 90,
          word: [
            { letter: 'c', value: 3, blank: false },
            { letter: 'a', value: 0, blank: true },
          ],
        },
      ],
    });
  });

  it('decodes a FriendList of account refs', () => {
    const b = new Builder(128);
    const id = b.createString('a-1');
    const dn = b.createString('Ann');
    fb.AccountRef.startAccountRef(b);
    fb.AccountRef.addAccountId(b, id);
    fb.AccountRef.addDisplayName(b, dn);
    const ref = fb.AccountRef.endAccountRef(b);
    const vec = fb.FriendList.createFriendsVector(b, [ref]);
    fb.FriendList.startFriendList(b);
    fb.FriendList.addFriends(b, vec);
    b.finish(fb.FriendList.endFriendList(b));
    expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]);
  });

  it('decodes a merge_required LinkResult without a session', () => {
    const b = new Builder(128);
    const status = b.createString('merge_required');
    const sid = b.createString('b-1');
    const sname = b.createString('Ann');
    fb.LinkResult.startLinkResult(b);
    fb.LinkResult.addStatus(b, status);
    fb.LinkResult.addSecondaryUserId(b, sid);
    fb.LinkResult.addSecondaryDisplayName(b, sname);
    fb.LinkResult.addSecondaryGames(b, 7);
    fb.LinkResult.addSecondaryFriends(b, 3);
    b.finish(fb.LinkResult.endLinkResult(b));
    expect(decodeLinkResult(b.asUint8Array())).toEqual({
      status: 'merge_required',
      secondaryUserId: 'b-1',
      secondaryDisplayName: 'Ann',
      secondaryGames: 7,
      secondaryFriends: 3,
      session: null,
    });
  });

  it('encodes an unlink request carrying the provider kind', () => {
    const r = fb.LinkUnlinkRequest.getRootAsLinkUnlinkRequest(new ByteBuffer(encodeLinkUnlink('telegram')));
    expect(r.kind()).toBe('telegram');
  });

  it('encodes a VK link request carrying the PKCE code-exchange inputs', () => {
    const r = fb.LinkVKRequest.getRootAsLinkVKRequest(new ByteBuffer(encodeLinkVK('the-code', 'dev-1', 'verifier-1')));
    expect(r.code()).toBe('the-code');
    expect(r.deviceId()).toBe('dev-1');
    expect(r.codeVerifier()).toBe('verifier-1');
  });

  it('round-trips the account-delete confirm + request-result wire', () => {
    const c = fb.AccountDeleteConfirm.getRootAsAccountDeleteConfirm(
      new ByteBuffer(encodeAccountDeleteConfirm('123456', 'DELETE')),
    );
    expect(c.code()).toBe('123456');
    expect(c.phrase()).toBe('DELETE');

    const b = new Builder(32);
    const m = b.createString('email');
    fb.AccountDeleteRequestResult.startAccountDeleteRequestResult(b);
    fb.AccountDeleteRequestResult.addMethod(b, m);
    b.finish(fb.AccountDeleteRequestResult.endAccountDeleteRequestResult(b));
    expect(decodeDeleteRequestResult(b.asUint8Array()).method).toBe('email');
  });

  it('passes an unlinked / changed LinkResult status straight through', () => {
    for (const status of ['unlinked', 'changed'] as const) {
      const b = new Builder(64);
      const s = b.createString(status);
      fb.LinkResult.startLinkResult(b);
      fb.LinkResult.addStatus(b, s);
      b.finish(fb.LinkResult.endLinkResult(b));
      expect(decodeLinkResult(b.asUint8Array()).status).toBe(status);
    }
  });

  it('decodes a merged LinkResult carrying a switched session', () => {
    const b = new Builder(128);
    const token = b.createString('tok-9');
    const uid = b.createString('a-1');
    const dn = b.createString('Kaya');
    fb.Session.startSession(b);
    fb.Session.addToken(b, token);
    fb.Session.addUserId(b, uid);
    fb.Session.addIsGuest(b, false);
    fb.Session.addDisplayName(b, dn);
    const sess = fb.Session.endSession(b);
    const status = b.createString('merged');
    fb.LinkResult.startLinkResult(b);
    fb.LinkResult.addStatus(b, status);
    fb.LinkResult.addSession(b, sess);
    b.finish(fb.LinkResult.endLinkResult(b));
    const r = decodeLinkResult(b.asUint8Array());
    expect(r.status).toBe('merged');
    expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya' });
  });

  it('decodes an Invitation with inviter and invitees', () => {
    const b = new Builder(256);
    const iid = b.createString('u-1');
    const idn = b.createString('Me');
    fb.AccountRef.startAccountRef(b);
    fb.AccountRef.addAccountId(b, iid);
    fb.AccountRef.addDisplayName(b, idn);
    const inviter = fb.AccountRef.endAccountRef(b);

    const aid = b.createString('inv-1');
    const adn = b.createString('Friend');
    const resp = b.createString('pending');
    fb.InvitationInvitee.startInvitationInvitee(b);
    fb.InvitationInvitee.addAccountId(b, aid);
    fb.InvitationInvitee.addDisplayName(b, adn);
    fb.InvitationInvitee.addSeat(b, 1);
    fb.InvitationInvitee.addResponse(b, resp);
    const invitee = fb.InvitationInvitee.endInvitationInvitee(b);
    const invitees = fb.Invitation.createInviteesVector(b, [invitee]);

    const id = b.createString('i-1');
    const variant = b.createString('scrabble_en');
    const dropout = b.createString('remove');
    const status = b.createString('pending');
    const gid = b.createString('');
    fb.Invitation.startInvitation(b);
    fb.Invitation.addId(b, id);
    fb.Invitation.addInviter(b, inviter);
    fb.Invitation.addInvitees(b, invitees);
    fb.Invitation.addVariant(b, variant);
    fb.Invitation.addTurnTimeoutSecs(b, 86400);
    fb.Invitation.addHintsAllowed(b, true);
    fb.Invitation.addHintsPerPlayer(b, 1);
    fb.Invitation.addDropoutTiles(b, dropout);
    fb.Invitation.addStatus(b, status);
    fb.Invitation.addGameId(b, gid);
    b.finish(fb.Invitation.endInvitation(b));

    const inv = decodeInvitation(b.asUint8Array());
    expect(inv.id).toBe('i-1');
    expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' });
    expect(inv.invitees).toHaveLength(1);
    expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
    expect(inv.variant).toBe('scrabble_en');
  });

  it('decodes a your_turn event, carrying the previous player name for the toast', () => {
    const b = new Builder(64);
    const gid = b.createString('g-1');
    const name = b.createString('Ann');
    fb.YourTurnEvent.startYourTurnEvent(b);
    fb.YourTurnEvent.addGameId(b, gid);
    fb.YourTurnEvent.addOpponentName(b, name);
    fb.YourTurnEvent.addMoveCount(b, 7);
    b.finish(fb.YourTurnEvent.endYourTurnEvent(b));
    expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({
      kind: 'your_turn',
      gameId: 'g-1',
      deadlineUnix: 0,
      opponentName: 'Ann',
      moveCount: 7,
    });
  });

  it('decodes a your_turn event with no opponent name as an empty string', () => {
    const b = new Builder(64);
    const gid = b.createString('g-2');
    fb.YourTurnEvent.startYourTurnEvent(b);
    fb.YourTurnEvent.addGameId(b, gid);
    b.finish(fb.YourTurnEvent.endYourTurnEvent(b));
    expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({
      kind: 'your_turn',
      gameId: 'g-2',
      deadlineUnix: 0,
      opponentName: '',
      moveCount: 0,
    });
  });

  it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
    const b = new Builder(64);
    const gid = b.createString('g-open');
    fb.MatchFoundEvent.startMatchFoundEvent(b);
    fb.MatchFoundEvent.addGameId(b, gid);
    b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
    expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
      kind: 'opponent_joined',
      gameId: 'g-open',
      state: undefined,
    });
  });

  it('decodes the match game even when matched is false (an open game awaiting an opponent)', () => {
    const b = new Builder(256);
    const id = b.createString('g-open');
    const variant = b.createString('scrabble_en');
    const dv = b.createString('v1');
    const status = b.createString('open');
    const er = b.createString('');
    fb.GameView.startGameView(b);
    fb.GameView.addId(b, id);
    fb.GameView.addVariant(b, variant);
    fb.GameView.addDictVersion(b, dv);
    fb.GameView.addStatus(b, status);
    fb.GameView.addPlayers(b, 2);
    fb.GameView.addToMove(b, 0);
    fb.GameView.addTurnTimeoutSecs(b, 86400);
    fb.GameView.addMoveCount(b, 0);
    fb.GameView.addEndReason(b, er);
    fb.GameView.addLastActivityUnix(b, BigInt(0));
    const game = fb.GameView.endGameView(b);
    fb.MatchResult.startMatchResult(b);
    fb.MatchResult.addMatched(b, false);
    fb.MatchResult.addGame(b, game);
    b.finish(fb.MatchResult.endMatchResult(b));
    const r = decodeMatchResult(b.asUint8Array());
    expect(r.matched).toBe(false);
    expect(r.game?.id).toBe('g-open');
    expect(r.game?.status).toBe('open');
  });

  it('decodes the profile banner block (campaigns + timings)', () => {
    const b = new Builder(256);
    const uid = b.createString('u-1');
    const m0 = b.createString('promo-en');
    const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
    fb.BannerCampaign.startBannerCampaign(b);
    fb.BannerCampaign.addWeight(b, 3);
    fb.BannerCampaign.addMessages(b, msgs);
    const camp = fb.BannerCampaign.endBannerCampaign(b);
    const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
    const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addBanner(b, banner);
    b.finish(fb.Profile.endProfile(b));

    const p = decodeProfile(b.asUint8Array());
    // A plain campaign carries no override (both sets null → the neutral theme tokens).
    expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'], overrideAll: null, overrideDark: null }]);
    expect(p.banner?.timings).toEqual({
      holdMs: 60000,
      edgePauseMs: 5000,
      scrollPxPerSec: 40,
      fadeOutMs: 1000,
      gapMs: 250,
      fadeInMs: 1000,
    });
  });

  it('decodes a campaign colour override (both sets)', () => {
    const b = new Builder(256);
    const uid = b.createString('u-1');
    const m0 = b.createString('branded');
    const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
    const s = (v: string) => b.createString(v);
    const [obg, ofg, ol, obgd, ofgd, old] = ['#aa0000', '#ffffff', '#ffdd00', '#330000', '#eeeeee', '#ffcc00'].map(s);
    const camp = fb.BannerCampaign.createBannerCampaign(b, 1, msgs, obg, ofg, ol, obgd, ofgd, old);
    const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
    const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addBanner(b, banner);
    b.finish(fb.Profile.endProfile(b));

    const p = decodeProfile(b.asUint8Array());
    expect(p.banner?.campaigns[0].overrideAll).toEqual({ bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' });
    expect(p.banner?.campaigns[0].overrideDark).toEqual({ bg: '#330000', fg: '#eeeeee', link: '#ffcc00' });
  });

  it('leaves the profile banner undefined when absent', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined();
  });

  it('decodes the profile interstitial-ad config', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, false);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addAds(b, ads);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).ads).toEqual({
      cooldownGlobalS: 300,
      cooldownVsAiS: 1800,
      cooldownHintS: 60,
      suppressed: false,
    });
  });

  it('decodes the profile active-game limits (guest tier)', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    const limits = fb.GameLimits.createGameLimits(b, 1, 1, 0);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addGameLimits(b, limits);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).gameLimits).toEqual({ vsAi: 1, random: 1, friends: 0 });
  });

  it('leaves the profile game limits undefined when absent', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).gameLimits).toBeUndefined();
  });

  it('carries the suppressed flag through the ad config', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, true);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addAds(b, ads);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).ads?.suppressed).toBe(true);
  });

  it('leaves the profile ads undefined when absent', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).ads).toBeUndefined();
  });

  it('decodes the profile variant preferences', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    const prefs = fb.Profile.createVariantPreferencesVector(b, [b.createString('erudit_ru'), b.createString('scrabble_en')]);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addVariantPreferences(b, prefs);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
  });

  it('decodes the profile dictionary versions into a per-variant map', () => {
    const b = new Builder(128);
    const uid = b.createString('u-1');
    const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0'));
    const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0'));
    const vec = fb.Profile.createDictVersionsVector(b, [en, er]);
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    fb.Profile.addDictVersions(b, vec);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' });
  });

  it('decodes an absent dict_versions vector to an empty map', () => {
    const b = new Builder(64);
    const uid = b.createString('u-1');
    fb.Profile.startProfile(b);
    fb.Profile.addUserId(b, uid);
    b.finish(fb.Profile.endProfile(b));
    expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({});
  });

  it('encodes the update-profile variant preferences', () => {
    const buf = encodeUpdateProfile({
      displayName: 'Kaya',
      preferredLanguage: 'ru',
      timeZone: 'UTC',
      awayStart: '00:00',
      awayEnd: '07:00',
      blockChat: false,
      blockFriendRequests: false,
      notificationsInAppOnly: true,
      variantPreferences: ['erudit_ru', 'scrabble_ru'],
    });
    const t = fb.UpdateProfileRequest.getRootAsUpdateProfileRequest(new ByteBuffer(buf));
    const out: string[] = [];
    for (let i = 0; i < t.variantPreferencesLength(); i++) out.push(t.variantPreferences(i));
    expect(out).toEqual(['erudit_ru', 'scrabble_ru']);
  });
});

// The live play loop exchanges alphabet indices, mapped through the per-variant
// table cached in lib/alphabet. Each test seeds the cache it needs (setAlphabet replaces
// the whole table), so they are independent of order.
describe('codec — alphabet on the wire', () => {
  it('encodes an ExchangeRequest as alphabet indices, blank as the sentinel', () => {
    setAlphabet('scrabble_en', [
      { index: 0, letter: 'a', value: 1 },
      { index: 1, letter: 'b', value: 3 },
    ]);
    const r = fb.ExchangeRequest.getRootAsExchangeRequest(
      new ByteBuffer(encodeExchange('g1', ['A', '?'], 'scrabble_en')),
    );
    expect(r.tilesLength()).toBe(2);
    expect(r.tiles(0)).toBe(0);
    expect(r.tiles(1)).toBe(BLANK_INDEX);
  });

  it('encodes a CheckWordRequest as alphabet indices', () => {
    setAlphabet('scrabble_en', [
      { index: 0, letter: 'a', value: 1 },
      { index: 2, letter: 'c', value: 3 },
      { index: 19, letter: 't', value: 1 },
    ]);
    const r = fb.CheckWordRequest.getRootAsCheckWordRequest(
      new ByteBuffer(encodeCheckWord('g1', 'CAT', 'scrabble_en')),
    );
    expect(r.wordLength()).toBe(3);
    expect([r.word(0), r.word(1), r.word(2)]).toEqual([2, 0, 19]);
  });

  it('carries the include_alphabet flag on a StateRequest', () => {
    const on = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', true)));
    expect(on.gameId()).toBe('g1');
    expect(on.includeAlphabet()).toBe(true);
    const off = fb.StateRequest.getRootAsStateRequest(new ByteBuffer(encodeStateRequest('g1', false)));
    expect(off.includeAlphabet()).toBe(false);
  });

  it('caches the alphabet table from a StateView and decodes the index rack to letters', () => {
    const b = new Builder(128);
    const la = b.createString('a');
    fb.AlphabetEntry.startAlphabetEntry(b);
    fb.AlphabetEntry.addIndex(b, 0);
    fb.AlphabetEntry.addLetter(b, la);
    fb.AlphabetEntry.addValue(b, 1);
    const ea = fb.AlphabetEntry.endAlphabetEntry(b);
    const lb = b.createString('b');
    fb.AlphabetEntry.startAlphabetEntry(b);
    fb.AlphabetEntry.addIndex(b, 1);
    fb.AlphabetEntry.addLetter(b, lb);
    fb.AlphabetEntry.addValue(b, 3);
    const eb = fb.AlphabetEntry.endAlphabetEntry(b);
    const alpha = fb.StateView.createAlphabetVector(b, [ea, eb]);
    const rack = fb.StateView.createRackVector(b, [0, BLANK_INDEX]);
    fb.StateView.startStateView(b);
    fb.StateView.addSeat(b, 0);
    fb.StateView.addRack(b, rack);
    fb.StateView.addBagLen(b, 10);
    fb.StateView.addHintsRemaining(b, 0);
    fb.StateView.addAlphabet(b, alpha);
    b.finish(fb.StateView.endStateView(b));

    // No GameView on the buffer, so decode falls back to the default variant 'scrabble_en';
    // the embedded table is cached under it and the rack [0, blank] decodes to letters.
    const sv = decodeStateView(b.asUint8Array());
    expect(sv.rack).toEqual(['A', '?']);
  });
});
