// In-memory mock implementation of GatewayClient. Drives the playable slice with no
// backend: it serves the seed data, applies plays/passes/exchanges/resigns to local
// state, fabricates plausible scores, and emits live events (a canned opponent reply,
// a match-found after enqueue) so the stream path is exercised too. This same fake is
// reused by the Playwright smoke. It is tree-shaken out of a production (non-mock)
// build.

import type {
  GatewayClient,
  PlacedTile,
  Unsubscribe,
} from '../client';
import { GatewayError } from '../client';
import type {
  AccountRef,
  BlockList,
  BlockStatus,
  ChatMessage,
  EvalResult,
  FeedbackReply,
  FeedbackState,
  FriendCode,
  GameList,
  ExportUrl,
  GcgExport,
  History,
  HintResult,
  Invitation,
  InvitationSettings,
  ConfirmLinkResult,
  LinkResult,
  MatchResult,
  MoveResult,
  OutgoingList,
  Profile,
  GameLimits,
  ProfileUpdate,
  PushEvent,
  Session,
  StateView,
  Stats,
  Variant,
  WordCheckResult,
  Wallet,
  WalletOrder,
  Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
import { noRepeatWordsFor } from '../variants';
import { seedMockAlphabets } from './alphabet';
import {
  ME,
  MOCK_FRIENDS,
  MOCK_INCOMING,
  MOCK_STATS,
  PROFILE,
  SESSION,
  mockInvitations,
  seedGames,
  type MockGame,
} from './data';

// emptyLinked is a "linked" LinkResult with no secondary summary or session switch.
function emptyLinked(): LinkResult {
  return {
    status: 'linked',
    secondaryUserId: '',
    secondaryDisplayName: '',
    secondaryGames: 0,
    secondaryFriends: 0,
    session: null,
  };
}

const POOL: Record<Variant, string> = {
  scrabble_en: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK',
  scrabble_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
  erudit_ru: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ',
};

function draw(variant: Variant, n: number): string[] {
  const pool = POOL[variant];
  const out: string[] = [];
  for (let i = 0; i < n; i++) out.push(pool[Math.floor(Math.random() * pool.length)]);
  return out;
}

function removeFromRack(rack: string[], tiles: PlacedTile[]): string[] {
  const next = [...rack];
  for (const t of tiles) {
    const want = t.blank ? '?' : t.letter.toUpperCase();
    const i = next.indexOf(want);
    if (i >= 0) next.splice(i, 1);
  }
  return next;
}

export class MockGateway implements GatewayClient {
  private readonly games = seedGames();
  private readonly profile: Profile = { ...PROFILE };
  private readonly subs = new Set<(e: PushEvent) => void>();
  private pendingMatch: string | null = null;
  // Feedback slice: a submission marks a message pending (blocks resend); mockAdminReply
  // clears it and raises the badge.
  private feedbackPending = false;
  private feedbackReply: FeedbackReply | null = null;
  private feedbackReplyUnread = false;
  // The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
  private openGameId: string | null = null;
  private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
  private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
  private outgoing: AccountRef[] = [];
  private blocks: AccountRef[] = [];
  private invitations: Invitation[] = mockInvitations();
  private readonly stats: Stats = { ...MOCK_STATS };
  private readonly drafts = new Map<string, string>();

  // The mock chip wallet: a web/native (direct) context holding a direct and a vk segment, so the
  // storefront, the priority draw (direct→vk→tg) and the web-spend warning (a value whose price
  // reaches into the vk segment) are all exercisable without a backend.
  private readonly mockWallet: Wallet = {
    segments: [
      { source: 'direct', chips: 120, spendable: true },
      { source: 'vk', chips: 400, spendable: true },
    ],
    adsForever: false,
    adsPaidUntilMs: 0,
    hints: 5,
    rewardChips: 5,
  };
  // The mock storefront: two chip-priced values (one cheap enough to draw direct only, one that
  // reaches into vk → the warning) and one RUB-priced chip pack (the direct-context method).
  private readonly mockCatalog: Catalog = {
    products: [
      { kind: 'value', productId: 'val-hints-50', title: '50 подсказок', chips: 100, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'hints', quantity: 50 }] },
      { kind: 'value', productId: 'val-noads-30', title: 'Без рекламы: 30 дней', chips: 300, moneyAmount: 0, moneyCurrency: '', atoms: [{ atomType: 'noads_days', quantity: 30 }] },
      { kind: 'pack', productId: 'pack-chips-100', title: '100 фишек', chips: 0, moneyAmount: 14900, moneyCurrency: 'RUB', atoms: [{ atomType: 'chips', quantity: 100 }] },
    ],
  };

  constructor() {
    // Seed the per-variant alphabet cache the rack, blank chooser and scoring read, so the
    // mock-driven UI is alphabet-agnostic without a backend.
    seedMockAlphabets();
    // e2e seam: `?guest` seeds a guest profile so the durable-only surfaces (Friends, Wallet) can
    // be asserted hidden. The mock profile is otherwise a durable account.
    if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('guest')) {
      this.profile.isGuest = true;
    }
  }

  setToken(_token: string | null): void {
    // The mock needs no auth; the real transport stores the bearer token.
  }

  private emit(e: PushEvent): void {
    for (const cb of this.subs) cb(e);
  }

  private game(id: string): MockGame {
    const g = this.games.get(id);
    if (!g) throw new GatewayError('not_found');
    return g;
  }

  private mySeat(g: MockGame): number {
    const s = g.view.seats.find((x) => x.accountId === ME);
    return s ? s.seat : 0;
  }

  // --- auth ---
  async authTelegram(initData: string): Promise<Session> {
    // e2e hook: an initData carrying this sentinel simulates a backend that rejects the launch,
    // so the Mini App boot-failure path (silent retries → boot-error screen) can be exercised.
    if (initData.includes('bootfail')) throw new GatewayError('unavailable');
    return { ...SESSION, isGuest: false };
  }
  async authVK(): Promise<Session> {
    return { ...SESSION, isGuest: false };
  }
  async authGuest(): Promise<Session> {
    return { ...SESSION };
  }
  async authGuestSilent(): Promise<Session> {
    // The reconciliation leg the native offline-first e2e drives (via __mock.reconcile): mint the
    // server guest the local guest adopts when the network returns. The mock has no kill switch, so
    // this is the same guest session as authGuest.
    return { ...SESSION };
  }
  async authEmailRequest(_email: string, _language: string, _pwa: boolean): Promise<void> {}
  async confirmEmailLink(_token: string): Promise<ConfirmLinkResult> {
    return { purpose: 'link', status: 'confirmed', session: null };
  }
  async authEmailLogin(): Promise<Session> {
    return { ...SESSION, isGuest: false };
  }

  // --- profile / lists ---
  async profileGet(): Promise<Profile> {
    return { ...this.profile };
  }
  async blockStatus(): Promise<BlockStatus> {
    // The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
    return { blocked: false, permanent: false, until: '', reason: '' };
  }

  // --- wallet ---
  async wallet(): Promise<Wallet> {
    return this.cloneWallet();
  }
  async catalog(): Promise<Catalog> {
    return { products: this.mockCatalog.products.map((p) => ({ ...p, atoms: p.atoms.map((a) => ({ ...a })) })) };
  }
  async walletBuy(productId: string): Promise<Wallet> {
    const p = this.mockCatalog.products.find((x) => x.productId === productId);
    if (!p || p.kind !== 'value') throw new GatewayError('product_not_found');
    // Draw the chip price across the segments by priority direct→vk→tg, mirroring the backend.
    let remaining = p.chips;
    for (const src of ['direct', 'vk', 'telegram'] as const) {
      if (remaining <= 0) break;
      const seg = this.mockWallet.segments.find((s) => s.source === src);
      if (!seg) continue;
      const take = Math.min(seg.chips, remaining);
      seg.chips -= take;
      remaining -= take;
    }
    if (remaining > 0) throw new GatewayError('insufficient_chips');
    for (const a of p.atoms) {
      if (a.atomType === 'hints') this.mockWallet.hints += a.quantity;
      if (a.atomType === 'noads_days') {
        this.mockWallet.adsPaidUntilMs = Math.max(Date.now(), this.mockWallet.adsPaidUntilMs) + a.quantity * 86_400_000;
      }
    }
    return this.cloneWallet();
  }

  async walletOrder(productId: string): Promise<WalletOrder> {
    const p = this.mockCatalog.products.find((x) => x.productId === productId);
    if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
    // No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
    // the provider hand-off without leaving the app.
    return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/yookassa?order=' + productId };
  }

  async walletReward(_nonce: string): Promise<Wallet> {
    // Mock rewarded credit: add the configured payout to the vk segment (no cap in the mock).
    const vk = this.mockWallet.segments.find((s) => s.source === 'vk');
    if (vk) vk.chips += this.mockWallet.rewardChips;
    return this.cloneWallet();
  }
  private cloneWallet(): Wallet {
    return {
      segments: this.mockWallet.segments.map((s) => ({ ...s })),
      adsForever: this.mockWallet.adsForever,
      adsPaidUntilMs: this.mockWallet.adsPaidUntilMs,
      hints: this.mockWallet.hints,
      rewardChips: this.mockWallet.rewardChips,
    };
  }
  async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
    // The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
    // in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
    // vs_ai game can generate real moves. Version is ignored (a single served file per variant).
    const res = await fetch(`/e2edict/${variant}.dawg`, { signal: opts?.signal });
    if (!res.ok) throw new Error(`fetchDict ${variant}: HTTP ${res.status}`);
    return res.arrayBuffer();
  }

  async reportLocalEval(_counts: Record<string, number>): Promise<void> {
    // Telemetry is a no-op in mock mode.
  }

  async gamesList(): Promise<GameList> {
    return {
      games: [...this.games.values()].map((g) => structuredClone(g.view)),
      atGameLimit: false,
    };
  }

  // --- lobby ---
  async lobbyEnqueue(variant: Variant, multipleWords: boolean, vsAi: boolean): Promise<MatchResult> {
    const id = crypto.randomUUID();
    if (vsAi) {
      // An honest-AI game starts active, already seated with a robot (rendered as 🤖 from the
      // vs_ai flag); there is no open/wait phase.
      const ai: MockGame = {
        view: {
          id,
          variant,
          dictVersion: 'v1',
          status: 'active',
          players: 2,
          toMove: 0,
          turnTimeoutSecs: 604800,
          multipleWordsPerTurn: multipleWords,
          noRepeatWords: noRepeatWordsFor(variant),
          moveCount: 0,
          endReason: '',
          lastActivityUnix: Math.floor(Date.now() / 1000),
          vsAi: true,
          unreadChat: false,
          unreadMessages: false,
          kind: 1,
          seats: [
            { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
            { seat: 1, accountId: 'robot', displayName: 'Robot', score: 0, hintsUsed: 0, isWinner: false },
          ],
        },
        moves: [],
        rack: draw(variant, 7),
        bagLen: 86,
        hintsRemaining: 1,
        chat: [],
      };
      this.games.set(id, ai);
      return { matched: true, game: structuredClone(ai.view) };
    }
    // The player enters an open game immediately and waits inside it; a robot opponent takes
    // the empty seat shortly (a sped-up version of the backend's 90–180 s wait), pushing
    // opponent_joined so the game UI restores from the "searching for opponent" state.
    const g: MockGame = {
      view: {
        id,
        variant,
        dictVersion: 'v1',
        status: 'open',
        players: 2,
        toMove: 0,
        turnTimeoutSecs: 86400,
        multipleWordsPerTurn: multipleWords,
        noRepeatWords: noRepeatWordsFor(variant),
        moveCount: 0,
        endReason: '',
        lastActivityUnix: Math.floor(Date.now() / 1000),
        vsAi: false,
        unreadChat: false,
        unreadMessages: false,
        kind: 2,
        seats: [
          { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
          { seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
        ],
      },
      moves: [],
      rack: draw(variant, 7),
      bagLen: 86,
      hintsRemaining: 1,
      chat: [],
    };
    this.games.set(id, g);
    this.openGameId = id;
    // The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
    // through the __mock hook (see lib/gateway.ts).
    setTimeout(() => this.fillOpponent(id), 3000);
    return { matched: false, game: structuredClone(g.view) };
  }

  // seatOpponent seats a robot in an open game's empty seat and returns the game (null once it is no
  // longer open). Shared by the live join (which then pushes opponent_joined) and the silent e2e
  // variant (which omits the push).
  private seatOpponent(id: string): MockGame | null {
    const game = this.games.get(id);
    if (!game || game.view.status !== 'open') return null;
    game.view.status = 'active';
    game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
    return game;
  }

  // fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
  // mock of a human or robot taking the seat. A no-op once the game is no longer open.
  private fillOpponent(id: string): void {
    const game = this.seatOpponent(id);
    if (game) this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
  }

  // joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
  // opponent to the most recently opened game on demand, making the waiting → joined transition
  // deterministic.
  joinPendingOpponent(): void {
    if (this.openGameId) this.fillOpponent(this.openGameId);
  }

  // joinPendingOpponentSilently seats the opponent WITHOUT pushing opponent_joined — the mock of an
  // event shed from a full hub buffer while the stream is alive, so the UI recovers only on a
  // foreground resync. e2e hook: window.__mock.joinOpponentSilently.
  joinPendingOpponentSilently(): void {
    if (this.openGameId) this.seatOpponent(this.openGameId);
  }

  // setGameLimits overrides the profile's per-kind active-game caps (the e2e hook
  // window.__mock.setGameLimits), then emits a profile notify so the app re-fetches the profile and
  // the New-Game screen's per-kind lock recomputes in place — the same path a guest→durable upgrade
  // takes to lift the lock.
  setGameLimits(l: GameLimits): void {
    this.profile.gameLimits = { ...l };
    this.emit({ kind: 'notify', sub: 'profile' });
  }

  async lobbyPoll(): Promise<MatchResult> {
    if (this.pendingMatch) {
      const g = this.games.get(this.pendingMatch);
      this.pendingMatch = null;
      if (g) return { matched: true, game: structuredClone(g.view) };
    }
    return { matched: false };
  }

  async lobbyCancel(): Promise<void> {
    // Dequeue: drop the pending substitution so a cancelled quick-match never arrives.
    this.pendingMatch = null;
  }

  // --- game ---
  // stateOf builds a player's StateView from a mock game (the viewer is always ME).
  private stateOf(g: MockGame): StateView {
    return {
      game: structuredClone(g.view),
      seat: this.mySeat(g),
      rack: [...g.rack],
      bagLen: g.bagLen,
      // hintsRemaining is the per-game allowance alone; the wallet lives on the profile and the
      // client adds it (lib/hints).
      hintsRemaining: g.hintsRemaining,
    };
  }

  async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
    return this.stateOf(this.game(gameId));
  }

  async gameHistory(gameId: string): Promise<History> {
    const g = this.game(gameId);
    return { gameId, moves: structuredClone(g.moves) };
  }

  async submitPlay(gameId: string, tiles: PlacedTile[], _variant: Variant): Promise<MoveResult> {
    const g = this.game(gameId);
    const seat = this.mySeat(g);
    if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
    const variant = g.view.variant;
    let score = tiles.reduce((s, t) => s + valueForLetter(variant, t.blank ? '?' : t.letter), 0);
    if (tiles.length === 7) score += 50;
    const total = g.view.seats[seat].score + score;
    const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
    const move = {
      player: seat,
      action: 'play' as const,
      dir,
      mainRow: tiles[0]?.row ?? 7,
      mainCol: tiles[0]?.col ?? 7,
      tiles: tiles.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })),
      words: [tiles.map((t) => t.letter).join('')],
      count: 1,
      score,
      total,
    };
    g.moves.push(move);
    g.view.seats[seat].score = total;
    g.view.moveCount += 1;
    g.rack = removeFromRack(g.rack, tiles);
    const drawn = Math.min(7 - g.rack.length, g.bagLen);
    g.rack.push(...draw(variant, drawn));
    g.bagLen -= drawn;
    g.view.toMove = (seat + 1) % g.view.players;
    this.drafts.delete(gameId);
    this.scheduleOpponentReply(gameId);
    return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
  }

  private async simpleAction(
    gameId: string,
    action: 'pass' | 'exchange' | 'resign',
    tiles: string[] = [],
  ): Promise<MoveResult> {
    const g = this.game(gameId);
    const seat = this.mySeat(g);
    if (g.view.toMove !== seat) throw new GatewayError('not_your_turn');
    if (action === 'exchange' && tiles.length > 0) {
      g.rack = removeFromRack(
        g.rack,
        tiles.map((l) => ({ row: 0, col: 0, letter: l, blank: l === '?' })),
      );
      g.rack.push(...draw(g.view.variant, tiles.length));
    }
    const move = {
      player: seat,
      action,
      dir: '',
      mainRow: 0,
      mainCol: 0,
      tiles: [],
      words: [],
      count: 0,
      score: 0,
      total: g.view.seats[seat].score,
    };
    g.moves.push(move);
    g.view.moveCount += 1;
    this.drafts.delete(gameId);
    if (action === 'resign') {
      g.view.status = 'finished';
      g.view.endReason = 'resignation';
      for (const s of g.view.seats) s.isWinner = s.seat !== seat;
    } else {
      g.view.toMove = (seat + 1) % g.view.players;
      this.scheduleOpponentReply(gameId);
    }
    return { move: structuredClone(move), game: structuredClone(g.view), rack: structuredClone(g.rack), bagLen: g.bagLen };
  }

  pass(gameId: string): Promise<MoveResult> {
    return this.simpleAction(gameId, 'pass');
  }
  exchange(gameId: string, tiles: string[], _variant: Variant): Promise<MoveResult> {
    return this.simpleAction(gameId, 'exchange', tiles);
  }
  resign(gameId: string): Promise<MoveResult> {
    return this.simpleAction(gameId, 'resign');
  }

  async hint(gameId: string): Promise<HintResult> {
    const g = this.game(gameId);
    if (g.hintsRemaining <= 0 && this.profile.hintBalance <= 0) throw new GatewayError('hint_unavailable');
    // Spend the per-game allowance first, then the shared wallet — mirroring the backend, so a
    // wallet hint in one game lowers the count shown in every other game (the bug this fixes).
    if (g.hintsRemaining > 0) g.hintsRemaining -= 1;
    else this.profile.hintBalance -= 1;
    const letter = g.rack.find((l) => l !== '?') ?? 'A';
    return {
      move: {
        player: this.mySeat(g),
        action: 'play',
        dir: 'H',
        mainRow: 7,
        mainCol: 7,
        tiles: [{ row: 7, col: 7, letter, blank: false }],
        words: [letter],
        count: 1,
        score: valueForLetter(g.view.variant, letter),
        total: 0,
      },
      hintsRemaining: g.hintsRemaining,
      walletBalance: this.profile.hintBalance,
    };
  }

  async evaluate(gameId: string, tiles: PlacedTile[], _variant: Variant, _signal?: AbortSignal): Promise<EvalResult> {
    const g = this.game(gameId);
    if (tiles.length === 0) return { legal: false, score: 0, words: [], dir: '' };
    let score = tiles.reduce((s, t) => s + valueForLetter(g.view.variant, t.blank ? '?' : t.letter), 0);
    if (tiles.length === 7) score += 50;
    const dir = new Set(tiles.map((t) => t.row)).size === 1 ? 'H' : 'V';
    return { legal: true, score, words: [tiles.map((t) => t.letter).join('')], dir };
  }

  async checkWord(_gameId: string, word: string, _variant: Variant): Promise<WordCheckResult> {
    return { word, legal: word.trim().length >= 2 };
  }
  async complaint(): Promise<void> {}

  // Hide a finished game from the caller's list: drop it from the in-memory store so a
  // subsequent gamesList omits it, mirroring the backend's per-account, finished-only rule.
  async hideGame(gameId: string): Promise<void> {
    const g = this.game(gameId);
    if (g.view.status !== 'finished') throw new GatewayError('game_active');
    this.games.delete(gameId);
    this.drafts.delete(gameId);
  }

  // --- draft: an in-memory composition store, so the reload/off-turn flow is
  // exercised without a backend. A committed move clears the actor's own draft, as on the server.
  async draftGet(gameId: string): Promise<string> {
    return this.drafts.get(gameId) ?? '';
  }
  async draftSave(gameId: string, json: string): Promise<void> {
    this.drafts.set(gameId, json);
  }

  // --- chat ---
  async chatPost(gameId: string, body: string): Promise<ChatMessage> {
    const g = this.game(gameId);
    const msg: ChatMessage = {
      id: crypto.randomUUID(),
      gameId,
      senderId: ME,
      kind: 'message',
      body,
      createdAtUnix: Math.floor(Date.now() / 1000),
    };
    g.chat.push(msg);
    return msg;
  }
  async chatList(gameId: string): Promise<ChatMessage[]> {
    return [...this.game(gameId).chat];
  }
  async feedbackSubmit(_body: string, _attachment: Uint8Array | null, _attachmentName: string, _channel: string): Promise<void> {
    this.feedbackPending = true;
    this.feedbackReply = null; // a new message: the previous operator reply no longer shows
  }
  async feedbackGet(): Promise<FeedbackState> {
    this.feedbackReplyUnread = false; // fetching the screen delivers (reads) the reply
    return {
      canSend: !this.feedbackPending,
      blockedReason: this.feedbackPending ? 'pending' : '',
      reply: this.feedbackReply,
    };
  }
  async feedbackUnread(): Promise<boolean> {
    return this.feedbackReplyUnread;
  }

  // mockAdminReply simulates an operator reply: it clears the pending message, sets the
  // reply and raises the badge via a live admin_reply notification. e2e hook:
  // window.__mock.adminReply.
  mockAdminReply(body = 'Thanks — see https://example.com/help for details.'): void {
    this.feedbackPending = false;
    this.feedbackReply = { body, repliedAtUnix: Math.floor(Date.now() / 1000) };
    this.feedbackReplyUnread = true;
    this.emit({ kind: 'notify', sub: 'admin_reply' });
  }
  // The mock holds no server-side unread state; the read ack is a no-op (the client clears its
  // own unread flag optimistically).
  async markChatRead(): Promise<void> {}

  async nudge(gameId: string): Promise<ChatMessage> {
    const g = this.game(gameId);
    const msg: ChatMessage = {
      id: crypto.randomUUID(),
      gameId,
      senderId: ME,
      kind: 'nudge',
      body: '',
      createdAtUnix: Math.floor(Date.now() / 1000),
    };
    g.chat.push(msg);
    return msg;
  }

  // --- friends ---
  private nameFor(id: string): string {
    return this.friends.find((f) => f.accountId === id)?.displayName ?? id;
  }
  async friendsList(): Promise<AccountRef[]> {
    return this.friends.map((f) => ({ ...f }));
  }
  async friendsIncoming(): Promise<AccountRef[]> {
    return this.incoming.map((f) => ({ ...f }));
  }
  async friendsOutgoing(): Promise<OutgoingList> {
    // The mock models human outgoing requests only (no disguised robots); robots stays empty.
    return { requests: this.outgoing.map((f) => ({ ...f })), robots: [] };
  }
  async friendRequest(accountId: string, _gameId?: string): Promise<void> {
    // The real backend requires a shared game; the mock records the outgoing request so
    // the game's "add to friends" item reads as sent across reloads.
    if (!this.outgoing.some((o) => o.accountId === accountId)) {
      this.outgoing.push({ accountId, displayName: this.nameFor(accountId) });
    }
  }
  async friendRespond(requesterId: string, accept: boolean): Promise<void> {
    const i = this.incoming.findIndex((r) => r.accountId === requesterId);
    if (i < 0) throw new GatewayError('request_not_found');
    const [r] = this.incoming.splice(i, 1);
    if (accept) this.friends.push(r);
    this.emit({ kind: 'notify', sub: 'friend_request' });
  }
  async friendCancel(_accountId: string): Promise<void> {}
  async unfriend(accountId: string): Promise<void> {
    this.friends = this.friends.filter((f) => f.accountId !== accountId);
  }
  async friendCodeIssue(): Promise<FriendCode> {
    return { code: '246813', expiresAtUnix: Math.floor(Date.now() / 1000) + 12 * 3600 };
  }
  async friendCodeRedeem(code: string): Promise<AccountRef> {
    const friend = { accountId: `code-${code}`, displayName: `Friend ${code}` };
    this.friends.push(friend);
    return { ...friend };
  }

  // --- blocks ---
  async blocksList(): Promise<BlockList> {
    // The mock models human blocks only (no disguised robots); robots stays empty.
    return { blocked: this.blocks.map((b) => ({ ...b })), robots: [] };
  }
  async block(accountId: string, _gameId?: string): Promise<void> {
    // A block hides the blocked person from the blocker's own friends list (the real
    // ListFriends filters them out), without deleting the underlying friendship.
    this.friends = this.friends.filter((f) => f.accountId !== accountId);
    if (!this.blocks.some((b) => b.accountId === accountId)) {
      this.blocks.push({ accountId, displayName: this.nameFor(accountId) });
    }
  }
  async unblock(accountId: string): Promise<void> {
    this.blocks = this.blocks.filter((b) => b.accountId !== accountId);
  }

  // --- invitations ---
  async invitationsList(): Promise<Invitation[]> {
    return this.invitations.map((i) => structuredClone(i));
  }
  async invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise<Invitation> {
    const inv: Invitation = {
      id: crypto.randomUUID(),
      inviter: { accountId: ME, displayName: 'You' },
      invitees: inviteeIds.map((id, k) => ({ accountId: id, displayName: this.nameFor(id), seat: k + 1, response: 'pending' })),
      variant: settings.variant,
      turnTimeoutSecs: settings.turnTimeoutSecs,
      hintsAllowed: settings.hintsAllowed,
      hintsPerPlayer: settings.hintsPerPlayer,
      multipleWordsPerTurn: settings.multipleWordsPerTurn,
      dropoutTiles: settings.dropoutTiles,
      status: 'pending',
      gameId: '',
      expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400,
    };
    this.invitations.push(inv);
    return structuredClone(inv);
  }
  private respondInvitation(invitationId: string, status: string): Invitation {
    const inv = this.invitations.find((i) => i.id === invitationId);
    if (!inv) throw new GatewayError('invitation_not_found');
    inv.status = status;
    this.invitations = this.invitations.filter((i) => i.id !== invitationId);
    return structuredClone(inv);
  }
  async invitationAccept(invitationId: string): Promise<Invitation> {
    return this.respondInvitation(invitationId, 'started');
  }
  async invitationDecline(invitationId: string): Promise<Invitation> {
    return this.respondInvitation(invitationId, 'declined');
  }
  async invitationCancel(invitationId: string): Promise<void> {
    this.invitations = this.invitations.filter((i) => i.id !== invitationId);
  }

  // --- profile / stats / history ---
  async profileUpdate(p: ProfileUpdate): Promise<Profile> {
    Object.assign(this.profile, p);
    return { ...this.profile };
  }
  // --- account linking & merge ---
  async linkEmailRequest(_email: string): Promise<void> {}
  async linkEmailConfirm(email: string, _code: string): Promise<LinkResult> {
    // An address containing "merge" stands in for one already owned by another
    // account, so the mock can drive the irreversible-merge confirmation.
    if (email.includes('merge')) {
      return {
        status: 'merge_required',
        secondaryUserId: 'mock-secondary',
        secondaryDisplayName: 'Ann',
        secondaryGames: 7,
        secondaryFriends: 3,
        session: null,
      };
    }
    this.profile.isGuest = false;
    this.profile.email = email;
    return emptyLinked();
  }
  // e2e hooks (window.__mock, see lib/gateway.ts): mockClearEmail drops the seeded email so the
  // sign-in section offers the add-email flow; mockConfirmEmailOutOfBand attaches the address
  // WITHOUT a live event — standing in for the one-tap confirm link opened in another browser,
  // so the open code form must reflect it by polling the profile, not by a push.
  mockClearEmail(): void {
    this.profile.email = '';
    this.emit({ kind: 'notify', sub: 'profile' });
  }
  mockConfirmEmailOutOfBand(email: string): void {
    this.profile.email = email;
  }
  async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
    this.profile.isGuest = false;
    this.profile.email = email;
    return { ...emptyLinked(), status: 'merged' };
  }
  async linkTelegram(_data: string): Promise<LinkResult> {
    this.profile.isGuest = false;
    this.profile.telegramLinked = true;
    return emptyLinked();
  }
  async linkTelegramMerge(_data: string): Promise<LinkResult> {
    this.profile.isGuest = false;
    this.profile.telegramLinked = true;
    return { ...emptyLinked(), status: 'merged' };
  }
  async linkVK(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
    this.profile.isGuest = false;
    this.profile.vkLinked = true;
    return emptyLinked();
  }
  async linkVKMerge(_code: string, _deviceId: string, _codeVerifier: string): Promise<LinkResult> {
    this.profile.isGuest = false;
    this.profile.vkLinked = true;
    return { ...emptyLinked(), status: 'merged' };
  }
  async linkUnlink(kind: string): Promise<LinkResult> {
    if (kind === 'telegram') this.profile.telegramLinked = false;
    if (kind === 'vk') this.profile.vkLinked = false;
    return { ...emptyLinked(), status: 'unlinked' };
  }
  async changeEmailRequest(_email: string): Promise<void> {}
  async changeEmailConfirm(email: string, _code: string): Promise<LinkResult> {
    // An address containing "taken" stands in for one confirmed by another account, so the
    // mock can drive the non-disclosing refusal (never a merge).
    if (email.includes('taken')) throw new GatewayError('email_taken');
    this.profile.email = email;
    return { ...emptyLinked(), status: 'changed' };
  }
  async deleteRequest(): Promise<{ method: 'email' | 'phrase' }> {
    return { method: this.profile.email ? 'email' : 'phrase' };
  }
  async deleteConfirm(_code: string, _phrase: string): Promise<void> {}
  async statsGet(): Promise<Stats> {
    return { ...this.stats };
  }
  async exportGcg(gameId: string): Promise<GcgExport> {
    const g = this.game(gameId);
    if (g.view.status !== 'finished') throw new GatewayError('game_active');
    return {
      gameId,
      filename: `game-${gameId}.gcg`,
      content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`,
    };
  }
  async exportUrl(gameId: string, kind: 'png' | 'gcg'): Promise<ExportUrl> {
    const g = this.game(gameId);
    if (g.view.status !== 'finished') throw new GatewayError('game_active');
    // A shape-faithful signed path; the e2e intercepts the route and serves bytes.
    return { path: `/dl/${gameId}/${kind}?e=9999999999&s=mock`, filename: `game-${gameId}.${kind}` };
  }

  // --- live stream ---
  subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
    this.subs.add(onEvent);
    // Mirror the gateway's idle keep-alive (an immediate heartbeat, then one every 10 s) so the
    // client's stream watchdog sees a live stream through an idle mock session, as in production.
    onEvent({ kind: 'heartbeat' });
    const hb = setInterval(() => onEvent({ kind: 'heartbeat' }), 10000);
    return () => {
      clearInterval(hb);
      this.subs.delete(onEvent);
    };
  }

  // Fabricate an opponent reply shortly after the human moves, then hand the turn back.
  private scheduleOpponentReply(gameId: string): void {
    setTimeout(() => {
      const g = this.games.get(gameId);
      if (!g || g.view.status !== 'active') return;
      const opp = (this.mySeat(g) + 1) % g.view.players;
      if (g.view.toMove !== opp) return;
      const cell = this.firstEmptyPair(g);
      const move = {
        player: opp,
        action: 'play' as const,
        dir: 'H' as const,
        mainRow: cell.row,
        mainCol: cell.col,
        tiles: [
          { row: cell.row, col: cell.col, letter: 'O', blank: false },
          { row: cell.row, col: cell.col + 1, letter: 'K', blank: false },
        ],
        words: ['OK'],
        count: 1,
        score: 6,
        total: g.view.seats[opp].score + 6,
      };
      g.moves.push(move);
      g.view.seats[opp].score = move.total;
      g.view.moveCount += 1;
      g.view.toMove = this.mySeat(g);
      this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen });
      this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, opponentName: g.view.seats[opp].displayName, moveCount: g.view.moveCount });
    }, 1600);
  }

  private firstEmptyPair(g: MockGame): { row: number; col: number } {
    const occupied = new Set(g.moves.flatMap((m) => m.tiles.map((t) => `${t.row},${t.col}`)));
    for (let row = 11; row < 15; row++) {
      for (let col = 0; col < 14; col++) {
        if (!occupied.has(`${row},${col}`) && !occupied.has(`${row},${col + 1}`)) return { row, col };
      }
    }
    return { row: 0, col: 0 };
  }
}
