// Domain model — plain TypeScript shapes the screens use, deliberately decoupled
// from the FlatBuffers wire types. Both the real transport (which decodes
// FlatBuffers) and the mock transport speak this model, so the UI never touches
// generated wire code directly.

export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';

/** Backend game status strings. 'open' is an auto-match game the player has entered
 *  but which is still waiting for an opponent (the opponent seat has no account). */
export type GameStatus = 'active' | 'finished' | 'open' | string;

/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;

/** Play orientation: H is across a row, V is down a column. */
export type Direction = 'H' | 'V';

export interface Tile {
  row: number;
  col: number;
  letter: string;
  blank: boolean;
}

export interface Seat {
  seat: number;
  accountId: string;
  displayName: string;
  score: number;
  hintsUsed: number;
  isWinner: boolean;
  /** Offline hotseat only: the seat resigned or was excluded by the host. Set by the local source so
   *  the finished-game medal ranking can place it last (no medal); undefined for online seats. */
  resigned?: boolean;
  /** The seat's account has been deleted: every social control aimed at it is hidden (add-friend,
   *  block, chat, nudge) — there is nobody behind it any more. Carried by the REST-backed views
   *  only; the live events leave it unset, so the delta reducers preserve the cached value. */
  deleted?: boolean;
}

export interface GameView {
  id: string;
  variant: Variant;
  dictVersion: string;
  status: GameStatus;
  players: number;
  toMove: number;
  turnTimeoutSecs: number;
  /** true = standard Scrabble; false = the single-word rule (Russian games). */
  multipleWordsPerTurn: boolean;
  /** true = a word already on the board cannot be laid again (the Erudit rule). Pinned per game at
   *  creation, so a game started before the rule existed reports false. The local move preview needs
   *  it to score the way the server does. */
  noRepeatWords: boolean;
  moveCount: number;
  endReason: string;
  /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */
  lastActivityUnix: number;
  /** true = an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend are disabled. */
  vsAi: boolean;
  /** true = a local offline pass-and-play (hotseat) game: 2-4 humans share one device. Set only by
   *  the local source; the online gateway never sets it. Drives the per-seat PIN-lock UI and the host
   *  menu (which replaces the hint control). A hotseat game is never vsAi. */
  hotseat?: boolean;
  /** Per-viewer flag: the requesting player has at least one unread chat entry (message or
   *  nudge) in this game. Set on the authoritative REST views (lobby list, game state,
   *  move result); the live-event GameView leaves it false (events bump unread instead). */
  unreadChat: boolean;
  /** Per-viewer flag: at least one unread entry is a real message (not just a nudge), so the
   *  badge can be coloured apart from the nudge-only case. Same REST-seeded lifecycle as
   *  unreadChat; the live-event GameView leaves it false. */
  unreadMessages: boolean;
  /** The game's origin for the active-game limits: 0 unknown (a pre-existing game, never gated),
   *  1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind against the caller's
   *  per-kind limits (Profile.gameLimits) to lock a capped New-Game start. Local/offline games
   *  leave it 0 (they never count toward the online limits). */
  kind: number;
  seats: Seat[];
}

export interface MoveRecord {
  player: number;
  action: MoveAction;
  dir: string;
  mainRow: number;
  mainCol: number;
  tiles: Tile[];
  words: string[];
  count: number;
  score: number;
  total: number;
}

/** A seated player's private view of a game. hintsRemaining is the per-game allowance alone; the
 *  purchasable hint wallet lives on the profile (payments), and the client adds it (see lib/hints). */
export interface StateView {
  game: GameView;
  seat: number;
  rack: string[];
  bagLen: number;
  hintsRemaining: number;
  /** For a vs_ai game, the seconds until the idle hint unlocks (computed by the source: the SERVER
   *  clock online, the device clock offline) — 0/undefined while open (the human's first move, or a
   *  non-vs_ai game). The client anchors a MONOTONIC countdown (performance.now()) to it on receipt,
   *  so a client clock change cannot skew it; a relaunch re-fetches a fresh value. See lib/hints. */
  hintUnlockLeftSeconds?: number;
  /** Offline hotseat only: the seat to move is PIN-locked, so its rack is withheld (empty) until the
   *  seat's PIN is entered (see LocalSource.unlockSeat). Absent/false everywhere else. */
  locked?: boolean;
}

export interface MoveResult {
  move: MoveRecord;
  game: GameView;
  /** The actor's refilled rack after the move, so the mover renders the next state without a refetch. */
  rack: string[];
  bagLen: number;
}

export interface HintResult {
  move: MoveRecord;
  hintsRemaining: number;
  walletBalance: number;
}

export interface EvalResult {
  legal: boolean;
  score: number;
  words: string[];
  /** Orientation the backend inferred for the play ("H"/"V"), empty when illegal. */
  dir: string;
  /** The play's main word when it is a real word the game has already used, so the rule (and not
   *  the dictionary) is what makes the play illegal — the composition reads as invalid but the
   *  caption says so in those words. Set only by the on-device evaluator, which decides the rule
   *  before the play ever reaches the server; absent on every other outcome. */
  repeatedWord?: string;
}

export interface WordCheckResult {
  word: string;
  legal: boolean;
}

export interface ChatMessage {
  id: string;
  gameId: string;
  senderId: string;
  kind: string;
  body: string;
  createdAtUnix: number;
}

/** FeedbackReply is the operator's answer shown on the feedback screen. */
export interface FeedbackReply {
  body: string;
  repliedAtUnix: number;
}

/**
 * FeedbackState is the feedback screen state. blockedReason is "" (can send),
 * "pending" (a previous message is still under review) or "banned"; reply is the
 * operator's answer to show, or null.
 */
export interface FeedbackState {
  canSend: boolean;
  blockedReason: string;
  reply: FeedbackReply | null;
}

/** One chip balance in the wallet: the funding source, the chip count, and whether it is
 *  spendable in the current execution context (false for a frozen VK-iOS balance or untrusted). */
export interface WalletSegment {
  source: string;
  chips: number;
  spendable: boolean;
}

/** The user-facing wallet: the context-visible chip segments and the context-applicable
 *  benefits (the no-ads term end as unix millis / forever flag, and the available hints). */
export interface Wallet {
  segments: WalletSegment[];
  adsForever: boolean;
  /** No-ads term end as unix milliseconds; 0 = no active term. */
  adsPaidUntilMs: number;
  hints: number;
  /** Chips a rewarded-video view earns in the current context; 0 = unavailable here (outside VK or
   *  unconfigured). The client shows the "watch for chips" button only when this is positive. */
  rewardChips: number;
}

/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
 *  "noads_days"/"tournament") and how many of it the product carries. */
// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the
// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server
// callback.
export interface WalletOrder {
  orderId: string;
  redirectUrl: string;
}

export interface CatalogAtom {
  atomType: string;
  quantity: number;
}

/** One storefront product for the caller's context. A "value" is bought with chips (chips is its
 *  uniform price, money fields zero); a "pack" funds chips with money (moneyAmount is its price in
 *  the context currency's minor units under moneyCurrency, chips zero). atoms lists what it grants. */
export interface CatalogProduct {
  kind: 'value' | 'pack';
  productId: string;
  title: string;
  chips: number;
  moneyAmount: number;
  moneyCurrency: string;
  atoms: CatalogAtom[];
}

/** The storefront: the products visible and purchasable in the caller's context — the chip-priced
 *  values and the chip packs priced in the context's payment method. */
export interface Catalog {
  products: CatalogProduct[];
}

export interface Profile {
  userId: string;
  displayName: string;
  preferredLanguage: string;
  timeZone: string;
  /** "HH:MM" daily away-window bounds, in timeZone. */
  awayStart: string;
  awayEnd: string;
  hintBalance: number;
  blockChat: boolean;
  blockFriendRequests: boolean;
  isGuest: boolean;
  /** Confine notifications to the in-app stream (no out-of-app platform push). */
  notificationsInAppOnly: boolean;
  /** Variants the player allows themselves to be matched into (Erudit-first). The New
   *  Game picker is gated by this set, which is never empty. */
  variantPreferences: Variant[];
  /** The advertising-banner block, present only for a viewer eligible to see it. */
  banner?: Banner;
  /** The post-move interstitial-ad config for the client-mirrored gate (cooldowns + suppressed). */
  ads?: AdsConfig;
  /** The account's confirmed email address ("" when none). */
  email: string;
  /** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
  telegramLinked: boolean;
  vkLinked: boolean;
  /** Current dictionary version per game variant (deployment-wide, not per-account). An
   *  offline-capable PWA reads it to preload the matching dawg for each enabled variant off
   *  this cold-start response. Empty only in a degenerate deployment with no resident dictionary. */
  dictVersions: Partial<Record<Variant, string>>;
  /** The caller's tier active-game caps per kind (-1 = unlimited); the New-Game screen counts the
   *  player's active games per kind from the lobby and locks a capped start. Absent from an old
   *  backend (then no lock applies). */
  gameLimits?: GameLimits;
}

/** GameKind labels the game_kind wire values for the active-game limits. */
export const GameKind = { vsAi: 1, random: 2, friends: 3 } as const;

/** The caller's tier active-game caps per kind (-1 = unlimited), resolved to the guest or durable
 *  tier server-side. The New-Game screen locks a start whose kind is at its cap. */
export interface GameLimits {
  vsAi: number;
  random: number;
  friends: number;
}

/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
 *  whether ads are suppressed in the current context (a no-ads benefit here, or the no_banner role). */
export interface AdsConfig {
  cooldownGlobalS: number;
  cooldownVsAiS: number;
  cooldownHintS: number;
  suppressed: boolean;
}

/** Banner is the advertising-banner block of an eligible viewer's profile. */
export interface Banner {
  campaigns: BannerCampaign[];
  timings: BannerTimings;
}

/** ColorTriple is a banner colour override's three "#rrggbb" colours. */
export interface ColorTriple {
  bg: string;
  fg: string;
  link: string;
}

/**
 * BannerColors is a campaign's optional palette: `all` overrides every theme, `dark`
 * further overrides the dark theme (either may be null — the neutral tokens then apply).
 * resolveBannerColors (lib/bannerColors) collapses it to the rendered theme's colours.
 */
export interface BannerColors {
  all: ColorTriple | null;
  dark: ColorTriple | null;
}

/** BannerCampaign is one campaign in the rotation feed. */
export interface BannerCampaign {
  /** GCD-reduced show weight for the client's smooth weighted round-robin. */
  weight: number;
  /** Messages in display order, already resolved to the viewer's bot language. */
  messages: string[];
  /** Colour override for every theme; null (or absent) keeps the neutral tokens. */
  overrideAll?: ColorTriple | null;
  /** Colour override for the dark theme only; null (or absent) falls back to overrideAll/tokens. */
  overrideDark?: ColorTriple | null;
}

/** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */
export interface BannerTimings {
  holdMs: number;
  edgePauseMs: number;
  scrollPxPerSec: number;
  fadeOutMs: number;
  gapMs: number;
  fadeInMs: number;
}

/** BlockStatus is the caller's current manual block, fetched after any call reports
 *  the "account_blocked" code. When blocked the app shows the terminal blocked screen
 *  and stops all push/poll. */
export interface BlockStatus {
  blocked: boolean;
  permanent: boolean;
  /** RFC3339 UTC instant for a temporary block; "" for a permanent one or when not blocked. */
  until: string;
  /** Operator reason resolved to the account's language; "" when none was cited. */
  reason: string;
}

/** The full editable profile sent to profileUpdate (overwrites every field). */
export interface ProfileUpdate {
  displayName: string;
  preferredLanguage: string;
  timeZone: string;
  awayStart: string;
  awayEnd: string;
  blockChat: boolean;
  blockFriendRequests: boolean;
  notificationsInAppOnly: boolean;
  variantPreferences: Variant[];
}

/** A referenced account with its display name (friend, blocked user, invitee). */
export interface AccountRef {
  accountId: string;
  displayName: string;
}

// RobotBlockEntry is one blocked disguised-robot opponent: a per-game record (not a real
// account) carrying the game name the player saw and the game/seat it was blocked in. Its id
// is used to unblock it.
export interface RobotBlockEntry {
  id: string;
  displayName: string;
  gameId: string;
  seat: number;
}

// BlockList is the caller's blocked humans plus the per-game disguised-robot blocks.
export interface BlockList {
  blocked: AccountRef[];
  robots: RobotBlockEntry[];
}

// RobotFriendRequestEntry is one per-game friend request to a disguised-robot opponent: a
// per-game record (not a real account) carrying the game name the player saw and the
// game/seat it was sent in, so the in-game card can re-mark that seat as already requested.
export interface RobotFriendRequestEntry {
  id: string;
  displayName: string;
  gameId: string;
  seat: number;
}

// OutgoingList is the caller's outgoing friend requests: human addressees already requested
// (pending or declined) plus the per-game disguised-robot requests.
export interface OutgoingList {
  requests: AccountRef[];
  robots: RobotFriendRequestEntry[];
}

/** A freshly issued one-time friend code (the plaintext is returned once). */
export interface FriendCode {
  code: string;
  expiresAtUnix: number;
}

/** One letter cell of a best-move word: its display letter, its tile value (0 for a
 *  blank) and whether it is a blank — enough to render it as a game tile without the
 *  variant's alphabet table. */
export interface BestMoveTile {
  letter: string;
  value: number;
  blank: boolean;
}

/** An account's highest-scoring single play within one variant: the variant, the play's
 *  total score and its main word as ordered tiles. */
export interface BestMove {
  variant: Variant;
  score: number;
  word: BestMoveTile[];
}

/** A durable account's lifetime statistics. bestMoves breaks the best move down per
 *  variant (with the word itself); it is empty for an account with no recorded play and
 *  lists only variants the account has played. */
export interface Stats {
  wins: number;
  losses: number;
  draws: number;
  maxGamePoints: number;
  maxWordPoints: number;
  /** Lifetime count of the player's plays (tile placements). */
  moves: number;
  /** Lifetime count of hints the player took (allowance + wallet). */
  hintsUsed: number;
  bestMoves: BestMove[];
}

/** Settings the inviter chooses for a friend game. */
export interface InvitationSettings {
  variant: Variant;
  turnTimeoutSecs: number;
  hintsAllowed: boolean;
  hintsPerPlayer: number;
  dropoutTiles: 'remove' | 'return';
  /** true = standard Scrabble; false = the single-word rule (Russian games). */
  multipleWordsPerTurn: boolean;
}

export interface InvitationInvitee {
  accountId: string;
  displayName: string;
  seat: number;
  response: 'pending' | 'accepted' | 'declined' | string;
}

export interface Invitation {
  id: string;
  inviter: AccountRef;
  invitees: InvitationInvitee[];
  variant: Variant;
  turnTimeoutSecs: number;
  hintsAllowed: boolean;
  hintsPerPlayer: number;
  /** true = standard Scrabble; false = the single-word rule (Russian games). */
  multipleWordsPerTurn: boolean;
  dropoutTiles: string;
  status: string;
  gameId: string;
  expiresAtUnix: number;
}

/** A finished game's GCG transcript for download/share. */
export interface GcgExport {
  gameId: string;
  filename: string;
  content: string;
}

/** A minted signed download link for a finished game's export artifact. path is
 *  relative — the client resolves it against its own origin (the SPA and the
 *  gateway edge share it), so the server never needs to know the public host. */
export interface ExportUrl {
  path: string;
  filename: string;
}

export interface Session {
  token: string;
  userId: string;
  isGuest: boolean;
  displayName: string;
}

// LinkResult is the outcome of an account link/merge/unlink/change step. status is
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
// another account — the secondary* fields summarise it for the irreversible
// confirmation), 'merged', 'unlinked' (a provider detached) or 'changed' (the email was
// switched). session is set only when the active account switched (a guest initiator
// whose durable counterpart won); the client adopts it.
export interface LinkResult {
  status: 'linked' | 'merge_required' | 'merged' | 'unlinked' | 'changed';
  secondaryUserId: string;
  secondaryDisplayName: string;
  secondaryGames: number;
  secondaryFriends: number;
  session: Session | null;
}

// ConfirmLinkResult is the outcome of a one-tap deeplink confirmation. purpose is
// 'login' (session carries the minted credential to sign in) or 'link' (status is
// 'confirmed' or 'merge_required' — the app finishes any merge from its manual flow).
export interface ConfirmLinkResult {
  purpose: 'login' | 'link';
  status: 'confirmed' | 'merge_required';
  session: Session | null;
}

export interface MatchResult {
  matched: boolean;
  game?: GameView;
}

export interface History {
  gameId: string;
  moves: MoveRecord[];
}

export interface GameList {
  games: GameView[];
  /** True when the caller has reached the simultaneous quick-game cap; the lobby then
   *  disables "New Game" and shows a notice. */
  atGameLimit: boolean;
}

/**
 * A live event delivered over the Subscribe stream. The game events carry the move as a
 * delta — move plus the post-move summary (and the bag size) — the client applies to its
 * cached game without a refetch; match_found / game_started / opponent_joined carry the
 * recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional
 * so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
 */
export type PushEvent =
  | { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number }
  | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number }
  | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView }
  | { kind: 'chat_message'; message: ChatMessage }
  | { kind: 'nudge'; gameId: string; fromUserId: string; senderName: string }
  | { kind: 'match_found'; gameId: string; state?: StateView }
  | { kind: 'opponent_joined'; gameId: string; state?: StateView }
  | { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
  | { kind: 'heartbeat' };
