// Central app state + actions. Holds the session/profile, client preferences, a
// transient toast, and the latest live event (screens react to it via $effect). All
// gateway calls funnel through here so errors map to one user-facing toast and an
// expired session logs out.

import type { BlockStatus, LinkResult, Profile, PushEvent, Session } from './model';
import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language';
import { startLocalEvalMetrics } from './localeval-metrics';
import { createStreamWatchdog } from './streamwatchdog';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
import {
  insideTelegram,
  telegramClose,
  telegramLaunchDiag,
  onTelegramPath,
  hasLaunchFragment,
  loadTelegramSDK,
  telegramColorScheme,
  telegramThemeParams,
  telegramContentSafeAreaTop,
  telegramSafeAreaInset,
  telegramDisableVerticalSwipes,
  telegramShowSettingsButton,
  telegramLaunch,
  type TelegramLaunch,
  telegramOnEvent,
  telegramSetChrome,
  telegramCloudAvailable,
  telegramCloudGet,
  telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, vkStorageGet, vkStorageSet, appearanceForBg } from './vk';
import type { LaunchDiag } from './launchdiag';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import {
  VK_ONBOARDING_KEY,
  VK_PREFS_KEY,
  decodeVKOnboarding,
  decodeVKPrefs,
  encodeVKOnboarding,
  encodeVKPrefs,
} from './vkprefs';
import { parseStartParam } from './deeplink';
import {
  clearOnboarding,
  clearSession,
  clearProfile,
  loadProfile,
  saveProfile,
  loadOnboarding,
  loadPrefs,
  loadSession,
  type OnboardingState,
  saveOnboarding,
  saveSession,
  savePrefs,
} from './session';
import type { CoachSeries } from './coachmark';
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
import { bootOffline, emit, initNetSignals, registerNetNudge, registerNetToast, registerRecovery } from './netstate.svelte';
import { latchUpdateNudge } from './update.svelte';
import { clearOfflinePref } from './offline';
import { initNativeNetwork } from './native';
import { clientChannel } from './channel';
import { localGuestId } from './localguest';
import { isConnectionCode } from './retry';
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
import { advanceCached } from './gamedelta';
import { clearLobby, patchLobbyGame, patchLobbyInvitation } from './lobbycache';
import type { BoardLabelMode } from './boardlabels';

export interface Toast {
  kind: 'error' | 'info';
  text: string;
  /** A monotonic id bumped on every showToast, so the Toast component re-keys and replays its
   *  entrance — the freshest toast cancels the previous one and starts its own appear cycle. */
  seq: number;
}

export const app = $state<{
  ready: boolean;
  /** Inside a Mini App, set when the launch failed to authenticate after its retries (e.g. the
   *  backend was down during a deploy). App.svelte then renders the boot-error retry screen
   *  instead of the web login — a Mini App has no manual sign-in to fall back to. */
  bootError: boolean;
  /** On a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch, set to a
   *  privacy-safe diagnostic snapshot. App.svelte then renders the compact, shareable launch-error
   *  screen (screens/LaunchError) — a probe for why Telegram delivered no initData (seen on some
   *  Android clients), or why /vk/ carried no signed launch — instead of bouncing to the landing
   *  (Telegram) or silently proceeding as a guest (VK). */
  launchError: LaunchDiag | null;
  /** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping
   *  the header title ten times in quick succession. A support aid; carries no secrets. */
  debugOpen: boolean;
  /** Whether the lobby's first cold load has settled (success or error). The loading splash
   *  (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
  lobbyReady: boolean;
  /** Whether the loading splash has finished and removed itself. Gates the App-level overlay
   *  so a warm intra-session return to the lobby shows no splash again. */
  splashDone: boolean;
  /** Whether the live-event stream is connected. The event hub is best-effort and never replays a
   *  missed event, so an open game waiting for an opponent uses this to recover: it polls the game
   *  state while the stream is down and refetches once on reconnect (see game/Game.svelte). */
  streamAlive: boolean;
  session: Session | null;
  profile: Profile | null;
  /** The caller's active manual block, or null. When set, App.svelte replaces every screen with
   *  the terminal blocked screen and all push/poll is stopped. */
  blocked: BlockStatus | null;
  /** Set once the account has been deleted: App.svelte shows the terminal "account deleted"
   *  screen and all push/poll is stopped. Reopening the app just creates a fresh account. */
  accountDeleted: boolean;
  /** A VK ID web-link authorization callback captured on boot (see lib/vkid): the browser
   *  returned from VK with an auth code. Profile consumes it on mount to finish the link or
   *  merge (a full-page redirect loses the route, so boot routes here). Null on a normal load. */
  vkLinkPending: VKLinkCallback | null;
  toast: Toast | null;
  lastEvent: PushEvent | null;
  theme: ThemePref;
  locale: Locale;
  reduceMotion: boolean;
  boardLabels: BoardLabelMode;
  /** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
   *  landscape layouts already fit the whole board and never auto-zoom). On by default. */
  zoomBoard: boolean;
  /** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
  onboarding: OnboardingState;
  /** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
   *  banner (components/PromoBanner) hides so it does not run above the dimmed onboarding layer,
   *  and reappears (per its own show logic) once onboarding closes. */
  coachActive: boolean;
  /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
  notifications: number;
  /** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
   *  game, for the lobby and in-game unread dot. Seeded from the authoritative REST views and
   *  raised by live chat/nudge events; cleared (with a backend ack) on opening the history/chat. */
  chatUnread: Record<string, boolean>;
  /** Per-game flag: at least one unread entry is a real message (not just a nudge), so the dot
   *  can be coloured apart from the nudge-only case. Same lifecycle as chatUnread; nudge events
   *  raise only chatUnread, message events raise both. */
  messageUnread: Record<string, boolean>;
  /** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
   *  with friend requests) and the Settings → Info badge. */
  feedbackReplyUnread: boolean;
  /** A monotone counter bumped when a payment-intake push signals the wallet changed; an open
   *  Wallet screen watches it and re-fetches in place. */
  walletRefresh: number;
  /** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend
   *  code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
   *  the bot instead of a scary error on the Friends screen. */
  staleInvite: boolean;
  /** Whether to show the Telegram "welcome" window: set when a deep-link friend code is
   *  redeemed successfully inside Telegram, greeting the arriving player and pointing them at
   *  the bot for the most convenient way to play. */
  welcomeRedeem: boolean;
  /** Monotonic counter bumped when the app returns to the foreground without the live stream
   *  having dropped. An open game watches it to refetch once, recovering an in-game event shed
   *  from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
  resync: number;
}>({
  ready: false,
  bootError: false,
  launchError: null,
  debugOpen: false,
  lobbyReady: false,
  splashDone: false,
  streamAlive: false,
  session: null,
  profile: null,
  blocked: null,
  accountDeleted: false,
  vkLinkPending: null,
  toast: null,
  lastEvent: null,
  theme: 'auto',
  locale: 'en',
  reduceMotion: false,
  boardLabels: 'beginner',
  zoomBoard: true,
  onboarding: { lobbyDone: false, gameDone: false },
  coachActive: false,
  notifications: 0,
  chatUnread: {},
  messageUnread: {},
  feedbackReplyUnread: false,
  walletRefresh: 0,
  staleInvite: false,
  welcomeRedeem: false,
  resync: 0,
});

let unsubscribeStream: (() => void) | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let toastSeq = 0;

// Background/foreground tracking, to silence the reconnect banner during a normal app
// suspend (iOS lock / home, Telegram tab switch) and reconnect quietly on return.
let backgrounded = false;
let foregroundedAt = 0;
const reconnectGraceMs = 4000;

/** documentHidden reports whether the page is currently hidden. */
function documentHidden(): boolean {
  return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}

/**
 * bannerSuppressed reports whether the connection banner should stay hidden: while
 * backgrounded, and for a short grace after returning to the foreground — a connection
 * dropped while suspended surfaces its error on resume, before the silent reconnect lands.
 */
function bannerSuppressed(): boolean {
  return backgrounded || documentHidden() || Date.now() - foregroundedAt < reconnectGraceMs;
}

function goBackground(): void {
  backgrounded = true;
  streamWatchdog.pause(); // do not count silence against a suspended (throttled) stream
}

function goForeground(): void {
  backgrounded = false;
  foregroundedAt = Date.now();
  if (!app.session) return;
  if (!app.streamAlive) {
    openStream(); // re-establish a stream dropped while away (its reconnect refetch then runs)
  } else {
    // The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an
    // event may have been shed from a full hub buffer while away; nudge an open game to refetch.
    app.resync++;
    streamWatchdog.resume(); // resume liveness watching paused on the way to the background
  }
  void refreshNotifications();
}

export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
  app.toast = { kind, text, seq: ++toastSeq };
  if (toastTimer) clearTimeout(toastTimer);
  // An info bubble lives exactly as long as its appear + ~1s hold + rise-and-fade animation (3s);
  // an error dwells longer (4s) so it is not missed.
  toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 3000);
}

/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
export function dismissToast(): void {
  if (toastTimer) {
    clearTimeout(toastTimer);
    toastTimer = null;
  }
  app.toast = null;
}

/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
export function dismissStaleInvite(): void {
  app.staleInvite = false;
}

/** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */
export function dismissWelcomeRedeem(): void {
  app.welcomeRedeem = false;
}

/** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened
 *  by tapping the header title ten times — a support aid that shows and shares client diagnostics. */
export function openDebug(): void {
  app.debugOpen = true;
}

export function closeDebug(): void {
  app.debugOpen = false;
}

/**
 * seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
 * list, a game's state, or a move result): unread is any unread entry, message whether one of them
 * is a real message (the badge colour). The live-event GameView omits both, so the live stream
 * raises them on receive rather than seeding from a GameView.
 */
export function seedChatUnread(gameId: string, unread: boolean, message: boolean): void {
  if ((app.chatUnread[gameId] ?? false) !== unread) {
    app.chatUnread = { ...app.chatUnread, [gameId]: unread };
  }
  if ((app.messageUnread[gameId] ?? false) !== message) {
    app.messageUnread = { ...app.messageUnread, [gameId]: message };
  }
}

/**
 * markChatRead clears a game's unread badge and tells the backend the player has read the chat —
 * called when the move history or the chat opens. It hits the backend only when something is
 * actually unread, so opening the history does not call the backend on every tap. The clear is
 * optimistic and the ack is best-effort: a failed ack leaves the server bit set, which the next
 * authoritative REST view (lobby list / game state) re-seeds, so the badge self-heals — it is
 * deliberately not restored here, since the in-game effect re-runs while the history is shown and
 * a restore would loop on a persistently failing ack.
 */
export function markChatRead(gameId: string): void {
  if (!app.chatUnread[gameId]) return;
  app.chatUnread = { ...app.chatUnread, [gameId]: false };
  if (app.messageUnread[gameId]) app.messageUnread = { ...app.messageUnread, [gameId]: false };
  void gateway.markChatRead(gameId).catch(() => {});
}

/** handleError maps a GatewayError to a toast; an invalid session logs out. A connectivity
 *  failure — or anything raised while the app is mid-reconnect — is shown by the "Connecting…"
 *  header indicator (and auto-retried), never a red toast. */
export function handleError(err: unknown): void {
  const code = err instanceof GatewayError ? err.code : '';
  if (code === 'session_invalid' || code === 'unauthenticated') {
    void logout();
    return;
  }
  if (code === 'account_blocked') {
    void enterBlocked();
    return;
  }
  // A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error
  // to surface — the offline chrome already signals the state; never a red toast.
  if (isConnectionCode(code) || code === 'offline' || !connection.online) return;
  haptic('error');
  showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
}

/**
 * enterBlocked switches the app to the terminal blocked screen and stops all push/poll. It is
 * triggered whenever any call reports the "account_blocked" code. It flips the screen
 * synchronously (a generic placeholder), then fetches the block's expiry and reason — the one
 * endpoint a blocked client may still reach — to render the precise message. If that fetch reports
 * the block was already lifted (a race), it recovers: clears the blocked screen and reopens the
 * stream.
 */
export async function enterBlocked(): Promise<void> {
  closeStream(); // stop the live stream; the blocked screen makes no further calls
  app.blocked ??= { blocked: true, permanent: true, until: '', reason: '' };
  try {
    const s = await gateway.blockStatus();
    if (s.blocked) {
      app.blocked = s;
    } else {
      app.blocked = null;
      if (app.session) openStream();
    }
  } catch {
    // Keep the placeholder blocked screen even if the details cannot be fetched.
  }
}

/**
 * viewingGame reports whether the game board for the given game id is the current route. That screen
 * maintains its own cache from live events, so the global stream handler must not also advance it (a
 * double application). The chat / dictionary sub-screens leave the board unmounted, so they do not
 * count as viewing — there the global handler keeps the cache warm for the return to the board.
 */
function viewingGame(gameId: string): boolean {
  return router.route.name === 'game' && router.route.params.id === gameId;
}

// The live stream carries a heartbeat every GATEWAY_PUSH_HEARTBEAT_INTERVAL (10 s by default), so a
// healthy stream delivers an event well inside this window even when the game is idle. A silence
// past it means the stream died without erroring (e.g. airplane mode cut the radio): tear the dead
// subscription down and drop, so the machine leaves "online" instead of waiting for the next call.
// 25 s tolerates a couple of missed heartbeats; it must stay above the server interval + slack.
const STREAM_IDLE_TIMEOUT_MS = 25000;
const streamWatchdog = createStreamWatchdog({
  timeoutMs: STREAM_IDLE_TIMEOUT_MS,
  onDead: () => {
    // Do not trip while suspended: timers are throttled and a backgrounded stream is legitimately
    // silent (the reopen on resume covers a genuine drop).
    if (backgrounded || documentHidden()) return;
    // Abort the dead subscription explicitly — an aborted stream skips onError (transport gates on
    // signal.aborted) — then follow the same drop path as an error close.
    unsubscribeStream?.();
    unsubscribeStream = null;
    app.streamAlive = false;
    if (!bannerSuppressed()) reportOffline();
    scheduleReconnect();
  },
});

function openStream(): void {
  closeStream();
  app.streamAlive = true;
  unsubscribeStream = gateway.subscribe(
    (e) => {
      reportOnline(); // a delivered event proves the gateway is reachable
      streamWatchdog.reset(); // any event (incl. the heartbeat) proves the stream is still live
      app.lastEvent = e;
      if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
        // While the player is in that game's comms hub (chat or dictionary tab), neither
        // toast nor bump the unread — the chat is a tap away and reloads on open.
        const inComms =
          (router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
          router.route.params.id === e.message.gameId;
        if (!inComms) {
          app.chatUnread = { ...app.chatUnread, [e.message.gameId]: true };
          // Only a real message colours the badge; a nudge delivered as a chat_message must not
          // raise the message flag (the separate nudge event below also leaves it untouched).
          if (e.message.kind !== 'nudge') {
            app.messageUnread = { ...app.messageUnread, [e.message.gameId]: true };
          }
          showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
        }
      } else if (e.kind === 'nudge') {
        // A nudge only reaches the awaited player; raise their unread badge for the game (unless
        // they are already in its chat), then toast — naming the nudger (their per-game seat name,
        // carried on the event), so it reads "<opponent>: …" like the your-turn toast; fall back to
        // the plain phrase when absent.
        const inComms =
          (router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
          router.route.params.id === e.gameId;
        if (!inComms) app.chatUnread = { ...app.chatUnread, [e.gameId]: true };
        showToast(e.senderName ? t('chat.nudgeBy', { name: e.senderName }) : t('chat.nudge'), 'info');
      } else if (e.kind === 'your_turn') {
        // Name the player who moved just before this one (the previous seat in turn order), so the
        // toast reads the same in games with any number of players. Fall back to the bare label when
        // no name is present (an older peer, or a gap event without one).
        showToast(e.opponentName ? t('game.yourTurnBy', { name: e.opponentName }) : t('game.yourTurn'), 'info');
      } else if (e.kind === 'opponent_moved' || e.kind === 'game_over') {
        // Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale
        // frame on the next navigation. The lobby snapshot is patched from the event's own GameView
        // (independent of whether this device has the game cached). The per-game cache is advanced
        // only for a game not currently in view — the mounted game board owns its own cache (see
        // game/Game.svelte), so skipping it avoids a double apply.
        if (e.game) patchLobbyGame(e.game);
        if (!viewingGame(e.gameId)) {
          const c = advanceCached(getCachedGame(e.gameId), e);
          if (c) setCachedGame(e.gameId, c.view, c.moves);
        }
      } else if (e.kind === 'opponent_joined') {
        // The opponent took an open game's empty seat: mirror the new status into the lobby snapshot,
        // and warm a not-currently-viewed game's own cache, so opening it from any screen is flash-free.
        // The mounted game board owns its cache (game/Game.svelte), so skip the game in view.
        if (e.state) {
          patchLobbyGame(e.state.game);
          if (!viewingGame(e.gameId)) {
            const c = advanceCached(getCachedGame(e.gameId), e);
            if (c) setCachedGame(e.gameId, c.view, c.moves);
          }
        }
      } else if (e.kind === 'match_found') {
        // Seed both caches from the event's initial state so the game renders instantly on arrival
        // and the new game is already in the lobby on a later return, then navigate.
        if (e.state) {
          setCachedGame(e.state.game.id, e.state, []);
          patchLobbyGame(e.state.game);
        }
        navigate(`/game/${e.gameId}`);
      } else if (e.kind === 'notify') {
        // A started invited game seeds its cache so opening it is instant and lands in the lobby
        // snapshot from any screen; the lobby badge stays on the authoritative refresh.
        if (e.sub === 'game_started' && e.state) {
          setCachedGame(e.state.game.id, e.state, []);
          patchLobbyGame(e.state.game);
        }
        // An invitation created / updated / withdrawn elsewhere: keep the lobby's invitations list
        // fresh from any screen — a new pending one is added, a consumed (started), declined,
        // cancelled or expired one is removed (see patchLobbyInvitation).
        if ((e.sub === 'invitation' || e.sub === 'invitation_update') && e.invitation) {
          patchLobbyInvitation(e.invitation);
        }
        // An operator answered the player's feedback: raise the Settings/Info badge.
        if (e.sub === 'admin_reply') {
          app.feedbackReplyUnread = true;
        }
        // The viewer's ad-banner eligibility may have changed (paid/hints/no_banner): re-fetch
        // the profile, which carries the banner block, so the banner shows or hides in place.
        if (e.sub === 'banner') {
          void refreshProfile();
        }
        // The viewer's own profile changed out of band (an email confirmed via the
        // one-tap deeplink opened in another browser): re-fetch it.
        if (e.sub === 'profile') {
          void refreshProfile();
        }
        // A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an
        // open Wallet screen watches, so it re-fetches in place (the return-focus poll is the
        // fallback when the live stream is down).
        if (e.sub === 'payment') {
          app.walletRefresh++;
        }
        void refreshNotifications();
      }
    },
    () => {
      streamWatchdog.clear();
      app.streamAlive = false;
      // A background suspend drops the single-shot stream. Keep the indicator hidden while
      // backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
      // reachability watcher and this scheduled retry recover it). Always schedule a retry.
      if (!bannerSuppressed()) reportOffline();
      scheduleReconnect();
    },
  );
  streamWatchdog.reset(); // start the liveness watch for this stream (armed by the first heartbeat)
}

/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
 * app is in the foreground (a single pending attempt at a time). */
function scheduleReconnect(): void {
  if (reconnectTimer || !app.session) return;
  reconnectTimer = setTimeout(() => {
    reconnectTimer = null;
    if (app.session && !app.streamAlive && !backgrounded && !documentHidden()) openStream();
  }, 4000);
}

/**
 * refreshProfile re-fetches the account profile (which carries the ad-banner block),
 * after a `banner` notify signals the viewer's banner eligibility changed. Best-effort:
 * a transient failure leaves the previous profile in place.
 */
export async function refreshProfile(): Promise<void> {
  if (!app.session) return;
  try {
    app.profile = await gateway.profileGet();
    void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
  } catch {
    // Best-effort; the banner just stays as it was until the next fetch.
  }
}

/**
 * refreshNotifications recomputes the badge count (incoming friend requests).
 * Authoritative poll, complementing the live 'notify' push. Game invitations have
 * their own lobby section, so they are not counted here. Guests have no social
 * surfaces, so it is a no-op for them.
 */
export async function refreshNotifications(): Promise<void> {
  if (!app.session || app.profile?.isGuest) {
    app.notifications = 0;
    return;
  }
  try {
    app.notifications = (await gateway.friendsIncoming()).length;
  } catch {
    // Best-effort; leave the previous count on a transient failure.
  }
}

/**
 * refreshFeedbackBadge recomputes whether an operator feedback reply awaits the
 * player (the Settings → Info badge, folded into the lobby ⚙️ badge). Authoritative
 * poll, complementing the live 'admin_reply' push. Guests cannot submit feedback, so
 * it is a no-op for them.
 */
export async function refreshFeedbackBadge(): Promise<void> {
  if (!app.session || app.profile?.isGuest) {
    app.feedbackReplyUnread = false;
    return;
  }
  try {
    app.feedbackReplyUnread = await gateway.feedbackUnread();
  } catch {
    // Best-effort; leave the previous flag on a transient failure.
  }
}

function closeStream(): void {
  streamWatchdog.clear();
  if (reconnectTimer) {
    clearTimeout(reconnectTimer);
    reconnectTimer = null;
  }
  unsubscribeStream?.();
  unsubscribeStream = null;
  app.streamAlive = false;
}

async function adoptSession(s: Session): Promise<void> {
  gateway.setToken(s.token);
  app.session = s;
  await saveSession(s);
  try {
    app.profile = await gateway.profileGet();
    // Persist the profile so a later offline cold start can launch from it (its variant
    // preferences, dictionary versions and display name) without a network fetch. Snapshot to a
    // plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
    // the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
    void saveProfile($state.snapshot(app.profile));
    // The live interface language follows the device — the explicit local choice (saved in
    // prefs) or the system guess made at bootstrap — and is no longer overridden from the
    // account here: the Telegram bot a user signs in through must not dictate the UI, so a
    // ru-bot launch on an English system stays English.
    //
    // The banner and out-of-app push are resolved server-side from preferred_language, so it
    // must track whatever language the UI actually shows — the explicit choice AND the system
    // guess. Reconcile it to the active locale on every adopt, not only after an explicit
    // Settings choice: a user who never opened Settings would otherwise be stuck on the
    // creation-time seed — e.g. an English banner under a Russian UI. This keeps every
    // server-rendered, language-dependent surface (banner, out-of-app push) aligned with the
    // interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when
    // already equal), so there is no write in the steady state.
    void persistLanguageToServer(app.locale);
  } catch (err) {
    handleError(err);
  }
  // A blocked account stays on the blocked screen: no live stream, no notification poll.
  if (app.blocked) return;
  openStream();
  void refreshNotifications();
}

// The cold-start reachability probe budget: how long the boot waits for the gateway to answer before
// launching from the cache into the implicit offline lobby (offline is auto-detected now — no dialog).
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;

/**
 * recover is the smart recovery the net-state store's watcher runs while connecting/offline: a
 * session-less native local guest reconciles a server guest (the reconcile attempt IS the reachability
 * probe — it needs no prior session), while any cached-session launch does a cheap authenticated read.
 * It resolves when the gateway is reachable (the store then heals to online) and rejects to stay offline.
 * bootstrap wires it to the store, which owns the poll/backoff timer and the OS-signal listeners.
 */
async function recover(): Promise<void> {
  const channel = clientChannel();
  if ((channel === 'android' || channel === 'ios') && !app.session) {
    await reconcileServerGuest();
    if (!app.session) throw new Error('still a local guest'); // reconcile did not adopt — stay offline
    return;
  }
  if (!(await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) throw new Error('gateway unreachable');
}

// A concurrency guard: the boot kick, the recovery poll and the online event can all try to reconcile
// at once — only the first proceeds; the rest see the in-flight flag (or the adopted session) and return.
let reconcileInFlight = false;

/**
 * reconcileServerGuest lazily mints a server guest for a native local-guest launch once the gateway is
 * reachable: with no cached session it silently calls auth.guest, adopts the returned session and clears
 * the auto-offline so online features (matchmaking, friends, the Profile sign-in surface) light up. It is
 * idempotent and best-effort — it never mints a second server guest (guarded on the absence of a session
 * and the in-flight flag), and a too-old client (a swallowed update_required — no overlay) or an
 * unreachable gateway just leaves the device a local guest until the next attempt. Local (device-only)
 * games are never migrated. Native channel only; a no-op elsewhere.
 */
async function reconcileServerGuest(): Promise<void> {
  if (reconcileInFlight || app.session) return;
  const channel = clientChannel();
  if (channel !== 'android' && channel !== 'ios') return;
  reconcileInFlight = true;
  try {
    const s = await gateway.authGuestSilent(app.locale);
    // The gateway answered, so heal the machine to online BEFORE adopting: adoptSession's profile fetch
    // and live stream ride the normal (online) transport, which the offline kill switch would otherwise
    // refuse. emit('callOk') is idempotent when already online (a re-entrant recover after adoption).
    emit('callOk');
    await adoptSession(s);
  } catch {
    // A too-old client (update_required, swallowed — stay a local guest, no overlay) or an unreachable
    // gateway: remain offline; the recovery poll / online event retries when the network returns.
  } finally {
    reconcileInFlight = false;
  }
}

/**
 * applyLinkResult applies a completed account link or merge: it adopts a
 * switched session (a guest initiator whose durable counterpart won, so the active
 * account changed) or, otherwise, refreshes the current profile in place.
 */
export async function applyLinkResult(r: LinkResult): Promise<void> {
  if (r.session && r.session.token) {
    // A guest initiator's merge switches the session to the surviving durable account. Its
    // device-local games (vs_ai / hotseat) were seated under the retired account's id, so
    // re-point them to the survivor: the lobby and the game header identify "me" by the active
    // account, and without this the merged-away game shows every seat as an opponent (the
    // in-game turn logic is seat-index based, so it still plays — the summary just misreads).
    const retiredId = app.session?.userId;
    await adoptSession(r.session);
    const survivorId = app.session?.userId;
    if (retiredId && survivorId && retiredId !== survivorId) {
      const { repointLocalGameSeats } = await import('./localgame/store');
      await repointLocalGameSeats(retiredId, survivorId);
    }
    return;
  }
  app.profile = await gateway.profileGet();
  // A guest who linked in place now has a durable account: push the active interface language
  // so the banner + push routing follow it (see adoptSession — reconciled regardless of an
  // explicit Settings choice).
  void persistLanguageToServer(app.locale);
}

/**
 * requestDeleteAccount starts account deletion and reports which step-up the account uses:
 * 'email' (a code was mailed) or 'phrase' (type the confirmation phrase).
 */
export async function requestDeleteAccount(): Promise<'email' | 'phrase'> {
  return (await gateway.deleteRequest()).method;
}

/**
 * confirmDeleteAccount confirms deletion with the mailed code or the typed phrase. On
 * success it switches to the terminal "account deleted" screen and stops all push/poll; it
 * throws on a bad code/phrase so the caller can let the user retry.
 */
export async function confirmDeleteAccount(code: string, phrase: string): Promise<void> {
  await gateway.deleteConfirm(code, phrase);
  closeStream();
  app.accountDeleted = true;
}

/**
 * closeDeletedApp closes the host Mini App from the terminal deleted screen (Telegram / VK).
 * A plain browser has nothing to close, so it is a no-op there — the terminal screen stays.
 */
export function closeDeletedApp(): void {
  if (insideTelegram()) {
    telegramClose();
    return;
  }
  if (insideVK()) {
    void vkClose();
  }
}

/**
 * syncTelegramChrome paints Telegram's header/background/bottom bar from the app's live
 * theme tokens, so the surrounding chrome matches the UI. Called after the theme is applied.
 */
function syncTelegramChrome(): void {
  if (typeof document === 'undefined') return;
  const cs = getComputedStyle(document.documentElement);
  telegramSetChrome(
    cs.getPropertyValue('--bg-elev').trim(),
    cs.getPropertyValue('--bg').trim(),
    cs.getPropertyValue('--bg-elev').trim(),
  );
}

/**
 * syncVKChrome paints VK's status bar (and, on Android, the action/navigation bars) from the app's
 * live theme tokens, so the surrounding VK chrome matches the UI. A no-op outside VK. Parity with
 * syncTelegramChrome.
 */
function syncVKChrome(): void {
  if (!insideVK() || typeof document === 'undefined') return;
  const cs = getComputedStyle(document.documentElement);
  const bg = cs.getPropertyValue('--bg').trim();
  const bgElev = cs.getPropertyValue('--bg-elev').trim();
  void vkSetViewSettings(appearanceForBg(bg), bgElev, bg);
}

/**
 * syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top
 * (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top
 * (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the
 * title in its band), and the device safe-area insets — notch / status bar (top), home indicator
 * (bottom) and the landscape notch sides (left / right) — into --tg-safe-top / --tg-safe-bottom /
 * --tg-safe-left / --tg-safe-right, so the header, rack and screen edges clear the device cut-outs.
 * Called on launch and on Telegram's safe-area / fullscreen change events.
 */
function syncTelegramSafeArea(): void {
  if (typeof document === 'undefined') return;
  const root = document.documentElement;
  const top = telegramContentSafeAreaTop();
  const safe = telegramSafeAreaInset();
  root.style.setProperty('--tg-content-top', `${top}px`);
  root.style.setProperty('--tg-safe-top', `${safe.top}px`);
  root.style.setProperty('--tg-safe-bottom', `${safe.bottom}px`);
  root.style.setProperty('--tg-safe-left', `${safe.left}px`);
  root.style.setProperty('--tg-safe-right', `${safe.right}px`);
  root.classList.toggle('tg-fullscreen', top > 0);
}

// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
const KEYBOARD_MIN_PX = 120;

/**
 * syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
 * `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
 * keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
 * Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
 * those values do not always cleanly revert. Tracking only the height left the top-anchored shell
 * misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too
 * keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
 * scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
 */
let keyboardOpen = false;
function syncViewport(): void {
  if (typeof document === 'undefined') return;
  const win = typeof window !== 'undefined' ? window : null;
  const vv = win ? win.visualViewport : null;
  const h = vv ? vv.height : win ? win.innerHeight : 0;
  const top = vv ? vv.offsetTop : 0;
  if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
  document.documentElement.style.setProperty('--vv-top', `${top}px`);
  const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
  if (open && !keyboardOpen) {
    const el = document.activeElement;
    if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
      requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
    }
  }
  keyboardOpen = open;
}

/**
 * syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides,
 * Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's
 * current themeParams when omitted. Called on launch with the launch snapshot and live on the
 * themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up
 * without a relaunch.
 */
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void {
  if (theme) applyTelegramTheme(theme);
  // Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
  // prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
  // back to the stored preference when the SDK omits it.
  applyTheme(telegramColorScheme() ?? app.theme);
  syncTelegramChrome();
}

/**
 * applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
 * colour scheme and theme (syncTelegramTheme), the matching header / background / bottom chrome,
 * the safe-area insets, and the swipe-down-to-minimise guard. It is idempotent, so both the
 * initial bootstrap and a manual launch retry call it.
 */
function applyTelegramChrome(launch: TelegramLaunch): void {
  syncTelegramTheme(launch.theme);
  // Mirror the safe-area insets and stop Telegram's swipe-down-to-minimise from fighting tile drag
  // / board scroll.
  syncTelegramSafeArea();
  telegramDisableVerticalSwipes();
}

/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
 *  the launch-error screen. A network that blocks telegram.org makes the script hang rather than
 *  fail fast (a connection refusal resolves immediately via the script's error event), so this only
 *  bounds a true hang; it is generous enough not to misfire on a slow but working network. */
const TELEGRAM_SDK_TIMEOUT_MS = 10000;

export async function bootstrap(): Promise<void> {
  // Record the cold start and start the adoption beacon (skipped under the mock harness, which
  // has no real backend). Deliberately before any await, so even an early-return launch path
  // (e.g. the Telegram launch-error screen) still counts as an app open.
  if (import.meta.env.MODE !== 'mock') startLocalEvalMetrics();
  // One-time cleanup of the retired deliberate-offline preference (offline is implicit now — the key is
  // never read again). Runs for every channel, before the Mini-App branches return.
  clearOfflinePref();
  const prefs = await loadPrefs();
  app.theme = prefs.theme ?? 'auto';
  app.reduceMotion = prefs.reduceMotion ?? false;
  app.boardLabels = prefs.boardLabels ?? 'beginner';
  app.zoomBoard = prefs.zoomBoard ?? true;
  app.onboarding = await loadOnboarding();
  applyTheme(app.theme);
  applyReduceMotion(app.reduceMotion);
  if (prefs.locale) {
    app.locale = prefs.locale;
    setLocale(prefs.locale);
  } else {
    const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
    app.locale = guess;
    setLocale(guess);
  }

  // Track the visual viewport (height + offset) so screens fit and follow the visible area above an
  // open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
  // the visual viewport.
  syncViewport();
  if (typeof window !== 'undefined' && window.visualViewport) {
    window.visualViewport.addEventListener('resize', syncViewport);
    window.visualViewport.addEventListener('scroll', syncViewport);
  }

  // Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
  // return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
  // feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
  // fires first (this listener is on the document, so it runs after the field's own handler).
  if (typeof document !== 'undefined') {
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
    });
  }

  // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
  // longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common
  // where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app
  // or the diagnostic screen below. Skipped on a plain web / native entry, which never needs it.
  if (onTelegramPath() || hasLaunchFragment()) {
    await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
  }
  // Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
  // deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
  // initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
  // clients), render the compact launch-error screen with a diagnostic snapshot the user can share
  // with the developer, instead of bouncing the visitor to the marketing landing.
  if (onTelegramPath() && !insideTelegram()) {
    app.launchError = telegramLaunchDiag();
    app.ready = true;
    return;
  }
  if (insideTelegram()) {
    const launch = telegramLaunch();
    applyTelegramChrome(launch);
    // Pull the device-independent display prefs (theme / reduce-motion / board labels) from
    // CloudStorage in the background so a change on another device follows the user here; the local
    // values applied above render instantly, so this reconciles without blocking launch.
    void reconcileCloudPrefs();
    // Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
    telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
    telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
    telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
    // Re-apply the theme live when the user switches Telegram's light/dark mode while the app is open.
    telegramOnEvent('themeChanged', () => syncTelegramTheme());
    // Telegram's native Settings button (Bot API 7.0) opens our Settings screen; the in-app gear
    // entry stays the primary path. No-op on clients predating the button.
    telegramShowSettingsButton(() => navigate('/settings'));
    await bootTelegram(launch);
    app.ready = true;
    return;
  }

  // The dedicated /vk/ entry opened without a signed VK launch (a plain browser tab, or a VK client
  // that delivered no launch parameters) shows the compact, shareable launch diagnostic instead of
  // silently proceeding as a guest — the VK counterpart of the /telegram/ launch-error screen. VK puts
  // its signed parameters in the launch URL at load, so there is nothing to wait for (hence no Retry).
  if (onVKPath() && !insideVK()) {
    app.launchError = vkLaunchDiag();
    app.ready = true;
    return;
  }

  // VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
  // authenticate from the signed launch parameters in the URL — the display name comes from
  // VKWebAppGetUserInfo, since VK omits it from the signed params.
  if (onVKPath() && insideVK()) {
    // Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
    // VK mobile webview's prefers-color-scheme does not track it).
    void vkOnScheme((scheme) => {
      if (app.theme === 'auto') applyTheme(scheme);
      // Repaint VK's status/nav bars to the resolved theme; this handler also fires on launch, so it
      // covers the initial paint (auto → the VK scheme just applied, explicit → the theme from boot).
      syncVKChrome();
    });
    // Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
    // CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
    void vkOnInsets((i) => {
      const root = document.documentElement;
      root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
      root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
      root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
      root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
    });
    await vkInit();
    // Restore the display prefs + coachmark flags from VK Bridge storage before the first paint — VK's
    // desktop iframe drops the browser-local store on reload (Firefox), so this is where the theme,
    // language and board settings come back. Awaited so the initial render is already correct.
    await reconcileVKPrefs();
    // The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep
    // it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes.
    void vkDisableSwipeBack();
    await bootVK();
    app.ready = true;
    return;
  }

  // On the plain web (both Mini-App branches returned above) register the install-only service
  // worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
  // and skipped in the mock build; see lib/pwa.svelte.
  registerServiceWorker();
  // Wire the net-state machine's signal sources for the rest of the session: the OS connectivity hints
  // (web navigator + native @capacitor/network), the smart recovery the store's watcher runs, and the
  // offline/online toast. Reached only on web / native — the Telegram/VK branches returned above, so
  // those channels are never fed an offline signal and the machine stays inert (always online) there.
  registerNetToast((toast) => showToast(t(toast === 'offline' ? 'net.offline' : 'net.online')));
  registerNetNudge(latchUpdateNudge);
  registerRecovery(recover);
  initNetSignals();
  void initNativeNetwork();

  const saved = await loadSession();
  // A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
  // from VK; capture and clear it here (it needs the restored session to link against).
  const vkcb = pendingVKLink();
  if (saved) {
    // Cold-start network auto-detect. With a cached profile to fall back on and no pending VK-link, do
    // not hang on adoptSession's retrying profile fetch when the network is down: if the interface is
    // down, or the gateway does not answer within a short window, launch straight from the cache into
    // the implicit offline lobby (no dialog — offline is detected now). The store's recovery poll heals
    // to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
    // so isStandalone gates it.
    const cachedProfile = await loadProfile();
    // A native app is offline-first, so a native launch — even a guest with no email — must fall back
    // to the cached session's offline lobby rather than hang ~25 s on adoptSession's retrying profile
    // fetch when the device is offline (e.g. airplane mode). Web keeps the installed-PWA + email gate.
    const nativeChannel = clientChannel() === 'android' || clientChannel() === 'ios';
    const canOffline = !vkcb && (nativeChannel || (!!cachedProfile && isStandalone() && !!cachedProfile.email));
    if (canOffline) {
      const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
      gateway.setToken(saved.token);
      const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
      if (!reachable) {
        bootOffline();
        app.session = saved;
        app.profile = cachedProfile;
        if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
        app.ready = true;
        return;
      }
    }
    await adoptSession(saved);
    if (vkcb) {
      // The full-page redirect lost the in-app route, so hand the callback to Profile, which
      // finishes the link or merge on mount.
      app.vkLinkPending = vkcb;
      navigate('/profile');
    } else if (router.route.name === 'login') {
      navigate('/');
    }
  } else if (clientChannel() === 'android' || clientChannel() === 'ios') {
    // Native offline-first cold boot: no cached server session, so enter as a device-local guest in the
    // implicit offline lobby and land straight there — never /login. bootOffline(true) drops into offline
    // and kicks the recovery poll now; recover() mints + adopts a server guest and heals to online once
    // the gateway is reachable, and until then the guest plays local vs_ai / hotseat with the APK's
    // bundled dictionaries. Web / PWA / Telegram / VK keep the online-session rule below.
    localGuestId(); // establish + persist the device-local identity from the very first launch
    bootOffline(true); // offline-first + kick reconciliation now, then poll until the network returns
    if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
  } else if (router.route.name !== 'login' && router.route.name !== 'confirm') {
    navigate('/login');
  }
  app.ready = true;
}

// Inside a Mini App the only identity is the platform session (Telegram or VK), so a failed launch
// must never fall back to the web login screen. A transient backend outage (a deploy rolling over)
// is retried a few times in silence; only then does the boot-error screen surface, from which Retry
// re-runs the same path (retryMiniAppBoot).
const TELEGRAM_BOOT_RETRIES = 2;
const TELEGRAM_BOOT_RETRY_MS = 1200;

function delay(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * bootTelegram authenticates a Mini App launch from its initData and routes any deep-link start
 * parameter, retrying a few times on a transient failure before raising the boot-error screen
 * (app.bootError). A blocked account is terminal — it switches straight to the blocked screen
 * without retrying.
 */
async function bootTelegram(launch: TelegramLaunch): Promise<void> {
  for (let attempt = 0; ; attempt++) {
    try {
      await adoptSession(await gateway.authTelegram(launch.initData));
      // A blocked account skips deep-link routing — the blocked screen overlays every route.
      if (!app.blocked) await routeStartParam(launch.startParam);
      app.bootError = false;
      return;
    } catch (err) {
      if (err instanceof GatewayError && err.code === 'account_blocked') {
        await enterBlocked();
        return;
      }
      if (attempt >= TELEGRAM_BOOT_RETRIES) {
        app.bootError = true;
        return;
      }
      await delay(TELEGRAM_BOOT_RETRY_MS);
    }
  }
}

/**
 * bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a
 * brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few
 * times on a transient failure before raising the boot-error screen, and a blocked account is
 * terminal. This MVP carries no VK deep-link routing.
 */
async function bootVK(): Promise<void> {
  const params = vkLaunchParams();
  const displayName = await vkUserName();
  for (let attempt = 0; ; attempt++) {
    try {
      await adoptSession(await gateway.authVK(params, displayName));
      // VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
      // (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
      // confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
      // launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
      // deep-link channel, and routes the lobby for an empty payload today.
      if (!app.blocked) await routeStartParam(vkStartParam());
      app.bootError = false;
      return;
    } catch (err) {
      if (err instanceof GatewayError && err.code === 'account_blocked') {
        await enterBlocked();
        return;
      }
      if (attempt >= TELEGRAM_BOOT_RETRIES) {
        app.bootError = true;
        return;
      }
      await delay(TELEGRAM_BOOT_RETRY_MS);
    }
  }
}

/**
 * retryMiniAppBoot re-attempts a Mini App launch from the boot-error screen's Retry button — the VK
 * boot on the /vk/ entry, the Telegram boot otherwise. It clears the error and shows the loading
 * state again, then runs the same retrying boot; on success the app renders normally, otherwise the
 * boot-error screen returns.
 */
export async function retryMiniAppBoot(): Promise<void> {
  app.bootError = false;
  app.ready = false;
  if (onVKPath()) {
    await vkInit();
    await bootVK();
  } else {
    await bootTelegram(telegramLaunch());
  }
  app.ready = true;
}

/**
 * retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If
 * sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs
 * the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the
 * current state. It never reloads — telegram-web-app.js consumes the launch fragment on first load,
 * so a reload could discard the very data we are waiting for.
 */
export async function retryTelegramLaunch(): Promise<void> {
  // Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
  await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
  if (!insideTelegram()) {
    app.launchError = telegramLaunchDiag();
    return;
  }
  app.launchError = null;
  app.ready = false;
  const launch = telegramLaunch();
  applyTelegramChrome(launch);
  await bootTelegram(launch);
  app.ready = true;
}

/**
 * routeStartParam navigates a Telegram deep-link start parameter to its target: a
 * specific game, the friends screen with a friend-code redemption, or the lobby
 * (where invitations surface as a badge).
 */
async function routeStartParam(param: string): Promise<void> {
  const link = parseStartParam(param);
  switch (link.kind) {
    case 'game':
      navigate(`/game/${link.id}`);
      return;
    case 'friendCode':
      try {
        const friend = await gateway.friendCodeRedeem(link.code);
        navigate('/friends');
        // Inside Telegram a successful invite-link redeem welcomes the arriving player and
        // points them at the bot (its native convenience); elsewhere a quiet toast suffices.
        if (insideTelegram()) {
          app.welcomeRedeem = true;
        } else {
          showToast(t('friends.added', { name: friend.displayName }));
        }
        void refreshNotifications();
      } catch (err) {
        const code = err instanceof GatewayError ? err.code : '';
        if (code === 'self_relation') {
          // Tapping your own invite link redeems your own code: a friendly note, not the
          // scary "can't do that to yourself" error.
          navigate('/friends');
          showToast(t('friends.selfInvite'));
        } else if (code === 'friend_code_invalid') {
          // A previously shared link whose single-use, 12h code is already spent or expired:
          // land in the lobby and gently point at the bot, rather than a red error on Friends.
          navigate('/');
          app.staleInvite = true;
        } else {
          navigate('/friends');
          handleError(err);
        }
      }
      return;
    default:
      navigate('/');
  }
}

export async function loginGuest(): Promise<void> {
  try {
    const s = await gateway.authGuest(app.locale);
    await adoptSession(s);
    navigate('/');
  } catch (err) {
    handleError(err);
  }
}

export async function requestEmailCode(email: string): Promise<boolean> {
  try {
    await gateway.authEmailRequest(email, app.locale, isStandalone());
    return true;
  } catch (err) {
    handleError(err);
    return false;
  }
}

export async function loginEmail(email: string, code: string): Promise<void> {
  try {
    const s = await gateway.authEmailLogin(email, code);
    await adoptSession(s);
    navigate('/');
  } catch (err) {
    handleError(err);
  }
}

// confirmDeeplink verifies a one-tap email deeplink token opened from a confirmation
// email. A login adopts the minted session and enters the app; a link reports whether
// it confirmed or needs the interactive merge. It throws on an invalid/expired token,
// which the confirm screen surfaces. This browser need not be signed in.
export async function confirmDeeplink(token: string): Promise<'login' | 'linked' | 'merge_required'> {
  const r = await gateway.confirmEmailLink(token);
  if (r.purpose === 'login' && r.session) {
    await adoptSession(r.session);
    navigate('/');
    return 'login';
  }
  return r.status === 'merge_required' ? 'merge_required' : 'linked';
}

export async function logout(): Promise<void> {
  closeStream();
  resetConnection();
  clearGameCache();
  clearLobby();
  // Replay the loading splash on the next cold lobby load after a re-login.
  app.lobbyReady = false;
  app.splashDone = false;
  gateway.setToken(null);
  await clearSession();
  await clearProfile();
  app.session = null;
  app.profile = null;
  navigate('/login');
}

/**
 * markOnboardingDone records that a first-run coachmark series has been completed (its last hint
 * was shown) and persists the flag, so the series never replays for this client. A series
 * interrupted before its last hint is left unmarked and replays from the start next launch.
 */
export function markOnboardingDone(series: CoachSeries): void {
  if (series === 'lobby') app.onboarding.lobbyDone = true;
  else app.onboarding.gameDone = true;
  void saveOnboarding({ ...app.onboarding });
  // Mirror to VK Bridge storage too (a no-op outside VK), so the first-run coachmarks do not replay
  // on every reload in VK's iframe, where the browser-local flag is dropped.
  void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ ...app.onboarding }));
}

/**
 * resetOnboarding clears both coachmark completion flags (the hidden DebugPanel control), so the
 * onboarding replays on the next launch — a manual re-test aid.
 */
export function resetOnboarding(): void {
  app.onboarding = { lobbyDone: false, gameDone: false };
  void clearOnboarding();
  void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ lobbyDone: false, gameDone: false }));
}

function persistPrefs(): void {
  void savePrefs({
    theme: app.theme,
    locale: app.locale,
    reduceMotion: app.reduceMotion,
    boardLabels: app.boardLabels,
    zoomBoard: app.zoomBoard,
  });
  // Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
  // across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
  // syncs via the durable account (Profile.preferredLanguage) instead.
  void telegramCloudSet(
    CLOUD_PREFS_KEY,
    encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
  );
  // Mirror the same display prefs — plus the locale — to VK Bridge storage, so they follow the user
  // in VK's desktop iframe where Firefox drops IndexedDB / localStorage (a no-op outside VK). The
  // locale is included here, unlike the Telegram bundle: on VK it has no other durable client home.
  void vkStorageSet(
    VK_PREFS_KEY,
    encodeVKPrefs({
      theme: app.theme,
      locale: app.locale,
      reduceMotion: app.reduceMotion,
      boardLabels: app.boardLabels,
      zoomBoard: app.zoomBoard,
    }),
  );
}

/**
 * reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
 * labels) from Telegram CloudStorage and applies any that differ from the current values, so a
 * change made on another Telegram device follows the user here. The local store is the
 * instant-render cache (read synchronously at boot); this runs once on launch after it and persists
 * what it applied. Theme is not re-applied visually — inside Telegram the colour scheme is
 * Telegram's to decide — only its stored value is updated. A no-op outside Telegram or when
 * CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
 */
async function reconcileCloudPrefs(): Promise<void> {
  if (!telegramCloudAvailable()) return;
  const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
  let changed = false;
  if (cloud.theme !== undefined && cloud.theme !== app.theme) {
    app.theme = cloud.theme;
    changed = true;
  }
  if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
    app.reduceMotion = cloud.reduceMotion;
    applyReduceMotion(app.reduceMotion);
    changed = true;
  }
  if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
    app.boardLabels = cloud.boardLabels;
    changed = true;
  }
  if (changed) persistPrefs();
}

/**
 * reconcileVKPrefs pulls the display prefs (theme / locale / reduce-motion / board labels / board
 * zoom) and the first-run coachmark flags from VK Bridge storage and applies any that are present,
 * then refreshes the local cache. It is the VK counterpart of reconcileCloudPrefs: VK's desktop
 * iframe loses IndexedDB / localStorage across reloads (Firefox), so the browser-local values read at
 * boot are only the defaults, while VK Bridge storage survives — this restores what the user last
 * chose onto the same VK identity. Awaited in the VK launch branch before the app renders, so the
 * first paint already carries the stored theme and language (no flash); VK's own loading cover is
 * still up until vkInit. The locale is applied through setLocale, not setLocalePref, so restoring it
 * never re-pushes to the server. A no-op outside VK.
 */
async function reconcileVKPrefs(): Promise<void> {
  if (!insideVK()) return;
  const p = decodeVKPrefs(await vkStorageGet(VK_PREFS_KEY));
  if (p.theme !== undefined && p.theme !== app.theme) {
    app.theme = p.theme;
    applyTheme(app.theme);
    syncVKChrome();
  }
  if (p.locale !== undefined && p.locale !== app.locale) {
    app.locale = p.locale;
    setLocale(p.locale);
  }
  if (p.reduceMotion !== undefined && p.reduceMotion !== app.reduceMotion) {
    app.reduceMotion = p.reduceMotion;
    applyReduceMotion(app.reduceMotion);
  }
  if (p.boardLabels !== undefined) app.boardLabels = p.boardLabels;
  if (p.zoomBoard !== undefined) app.zoomBoard = p.zoomBoard;
  const ob = decodeVKOnboarding(await vkStorageGet(VK_ONBOARDING_KEY));
  if (ob.lobbyDone !== undefined) app.onboarding.lobbyDone = ob.lobbyDone;
  if (ob.gameDone !== undefined) app.onboarding.gameDone = ob.gameDone;
  // Refresh the browser-local cache with what we restored (best-effort — it may be blocked in this
  // iframe, and VK Bridge storage stays the durable copy). savePrefs directly, not persistPrefs, to
  // avoid writing straight back to the VK/Telegram stores we just read from.
  void savePrefs({
    theme: app.theme,
    locale: app.locale,
    reduceMotion: app.reduceMotion,
    boardLabels: app.boardLabels,
    zoomBoard: app.zoomBoard,
  });
  void saveOnboarding({ ...app.onboarding });
}

export function setTheme(theme: ThemePref): void {
  app.theme = theme;
  applyTheme(theme);
  syncVKChrome();
  persistPrefs();
}

export function setLocalePref(locale: Locale): void {
  app.locale = locale;
  setLocale(locale);
  persistPrefs();
  void persistLanguageToServer(locale);
}

/**
 * persistLanguageToServer writes the chosen interface language through to the
 * durable account's preferred_language, so the single Settings control is the
 * source of truth (guests keep only the client preference). Best-effort.
 */
async function persistLanguageToServer(locale: Locale): Promise<void> {
  const p = app.profile;
  if (!p || !languageNeedsServerSync(p, locale)) return;
  try {
    app.profile = await gateway.profileUpdate({
      displayName: p.displayName,
      preferredLanguage: locale,
      timeZone: p.timeZone,
      awayStart: p.awayStart,
      awayEnd: p.awayEnd,
      blockChat: p.blockChat,
      blockFriendRequests: p.blockFriendRequests,
      notificationsInAppOnly: p.notificationsInAppOnly,
      variantPreferences: p.variantPreferences,
    });
  } catch {
    // The client locale already changed; the server sync is best-effort.
  }
}

export function setReduceMotion(on: boolean): void {
  app.reduceMotion = on;
  applyReduceMotion(on);
  persistPrefs();
}

export function setBoardLabels(mode: BoardLabelMode): void {
  app.boardLabels = mode;
  persistPrefs();
}

export function setZoomBoard(on: boolean): void {
  app.zoomBoard = on;
  persistPrefs();
}

// Background/foreground lifecycle: silence the reconnect banner during a suspend and
// reconnect quietly on return (and refresh the lobby badge for any push missed while
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
// pageshow/pagehide pair (iOS), and Telegram's own activated/deactivated (Bot API 8.0).
if (typeof document !== 'undefined') {
  document.addEventListener('visibilitychange', () =>
    document.visibilityState === 'visible' ? goForeground() : goBackground(),
  );
}
if (typeof window !== 'undefined') {
  window.addEventListener('pageshow', goForeground);
  window.addEventListener('pagehide', goBackground);
}
telegramOnEvent('activated', goForeground);
telegramOnEvent('deactivated', goBackground);

// Mock-mode e2e seam (tree-shaken from a production build): drive the live-stream lifecycle so the
// e2e can exercise the open-game fallbacks for a missed opponent_joined. `drop` tears the stream
// down without scheduling a reconnect — so only the in-game poll can then recover — and `restore`
// reopens it (the reconnect catch-up path). Never wired outside mock mode.
if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
  (window as unknown as { __stream?: { drop(): void; restore(): void } }).__stream = {
    drop: closeStream,
    restore: openStream,
  };
  // Drive the terminal blocked screen from the e2e (the mock never blocks of its own accord). It
  // mirrors enterBlocked: flip the screen and stop the live stream.
  (window as unknown as { __block?: (b?: Partial<BlockStatus>) => void }).__block = (b) => {
    closeStream();
    app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
  };
  // Expose the router so the e2e can assert navigate() updates the route synchronously (no
  // hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
  // render the previous route under the new screen (the empty-hash lobby flashing under login).
  (window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
    navigate,
    route: () => router.route.name,
  };
  // Drive the native offline-first reconciliation from the e2e: the net-state store's recovery poll that
  // fires it in a real build is inert under the mock, so the spec calls this to simulate "the network
  // returned" and prove a server guest is minted + adopted and the machine heals to online.
  (window as unknown as { __native?: { reconcile(): void } }).__native = {
    reconcile: () => void reconcileServerGuest(),
  };
}
