<script lang="ts">
  import { onDestroy, onMount } from 'svelte';
  import Screen from '../components/Screen.svelte';
  import Menu from '../components/Menu.svelte';
  import TabBar from '../components/TabBar.svelte';
  import HoldConfirm from '../components/HoldConfirm.svelte';
  import Modal from '../components/Modal.svelte';
  import Board from './Board.svelte';
  import Rack from './Rack.svelte';
  import Chat from './Chat.svelte';
  import { gateway } from '../lib/gateway';
  import { app, handleError, showToast } from '../lib/app.svelte';
  import { GatewayError } from '../lib/client';
  import { t } from '../lib/i18n/index.svelte';
  import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
  import { replay } from '../lib/board';
  import { centre, premiumGrid } from '../lib/premiums';
  import { variantNameKey } from '../lib/variants';
  import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
  import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
  import { shareOrDownloadGcg } from '../lib/share';
  import { getCachedGame, setCachedGame } from '../lib/gamecache';
  import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
  import {
    BLANK,
    newPlacement,
    place,
    placementFromHint,
    rackView,
    recallAt,
    reset,
    toSubmit,
    type Placement,
  } from '../lib/placement';

  let { id }: { id: string } = $props();

  let view = $state<StateView | null>(null);
  let moves = $state<MoveRecord[]>([]);
  let placement = $state<Placement>(newPlacement([]));
  let preview = $state<EvalResult | null>(null);
  let dirOverride = $state<Direction | undefined>(undefined);
  let busy = $state(false);
  let zoomed = $state(false);
  let selected = $state<number | null>(null);
  let focus = $state<{ row: number; col: number } | null>(null);
  // A stable id per rack slot, permuted together with the letters on shuffle, so the rack
  // tiles fly to their new positions (Rack's hop animation) instead of relabelling in place.
  let rackIds = $state<number[]>([]);
  let shuffling = $state(false);
  let panel = $state<'none' | 'chat'>('none');
  let historyOpen = $state(false);
  let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
  let exchangeOpen = $state(false);
  let exchangeSel = $state<number[]>([]);
  let checkOpen = $state(false);
  let checkWord = $state('');
  let checkResult = $state<{ word: string; legal: boolean } | null>(null);
  let resignOpen = $state(false);
  let messages = $state<ChatMessage[]>([]);
  let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);

  const checkedWords = new Map<string, boolean>();
  let cooling = $state(false);

  const variant = $derived(view?.game.variant ?? 'english');
  const board = $derived(replay(moves));
  const premium = $derived(premiumGrid(variant));
  const ctr = $derived(centre(variant));
  const pendingMap = $derived(
    new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
  );
  const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
  // Highlight the last word with a dark tile bg; while placing, only the pending tiles
  // are highlighted. It flashes when the opponent just moved and it is now our turn.
  const highlight = $derived(
    placement.pending.length > 0 || !lastPlay || (!!view && view.game.status !== 'active')
      ? new Set<string>()
      : new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
  );
  const flash = $derived(
    !!lastPlay &&
      !!view &&
      view.game.status === 'active' &&
      lastPlay.player !== view.seat &&
      view.game.toMove === view.seat,
  );
  const slots = $derived(rackView(placement));
  const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
  const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
  const gameOver = $derived(!!view && view.game.status !== 'active');
  const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
  // Nudge cooldown (one per hour per game, mirrored from the backend): the control is
  // disabled for an hour after the player's own last nudge. nudgeTick re-evaluates it on a
  // timer while the chat is open, so it re-enables without waiting for a new message.
  const nudgeCooldownSecs = 3600;
  let nudgeTick = $state(0);
  const nudgeOnCooldown = $derived.by(() => {
    void nudgeTick;
    const mine = app.session?.userId ?? '';
    const last = messages.reduce(
      (mx, m) => (m.kind === 'nudge' && m.senderId === mine ? Math.max(mx, m.createdAtUnix) : mx),
      0,
    );
    return last > 0 && Date.now() / 1000 - last < nudgeCooldownSecs;
  });

  async function load() {
    try {
      // Ask for the alphabet table only on a per-variant cache miss (the first open of a
      // game whose variant the client has not cached yet); steady-state polls omit it.
      const includeAlphabet = !view || !hasAlphabet(view.game.variant);
      const [st, hist] = await Promise.all([
        gateway.gameState(id, includeAlphabet),
        gateway.gameHistory(id),
      ]);
      view = st;
      moves = hist.moves;
      setCachedGame(id, st, hist.moves);
      placement = newPlacement(st.rack);
      rackIds = st.rack.map((_, i) => i);
      preview = null;
      selected = null;
      dirOverride = undefined;
    } catch (e) {
      handleError(e);
    }
  }
  async function loadChat() {
    try {
      messages = await gateway.chatList(id);
    } catch (e) {
      handleError(e);
    }
  }
  onMount(() => {
    // Guard against an accidental swipe-close losing the open game (Telegram).
    telegramClosingConfirmation(true);
    // Render instantly from the cache (a game opened before), then refresh in the
    // background. A cold open shows the loading state until load() resolves.
    const cached = getCachedGame(id);
    if (cached) {
      view = cached.view;
      moves = cached.moves;
      placement = newPlacement(cached.view.rack);
      rackIds = cached.view.rack.map((_, i) => i);
    }
    void load();
  });

  $effect(() => {
    const e = app.lastEvent;
    if (!e) return;
    if (e.kind === 'opponent_moved' && e.gameId === id) {
      // Skip the echo of my own move (the backend now notifies the actor too, for the
      // player's other devices): this device already reloaded after the submit.
      if (e.seat !== view?.seat) void load();
    } else if (e.kind === 'your_turn' && e.gameId === id) void load();
    else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
    else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
  });

  // Tick the nudge cooldown while the chat is open so the control re-enables on time.
  $effect(() => {
    if (panel !== 'chat') return;
    const h = setInterval(() => (nudgeTick += 1), 20000);
    return () => clearInterval(h);
  });

  function isCoarse(): boolean {
    return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
  }

  // --- tile placement: pointer drag + tap ---
  // A drag carries its source: a rack slot (lift a tile onto the board) or a pending
  // board cell (drag the tile back to the rack). downInfo also holds the press origin,
  // for the movement threshold that distinguishes a drag from a tap.
  type DragSrc = { from: 'rack'; index: number } | { from: 'board'; row: number; col: number };
  let downInfo: { src: DragSrc; x0: number; y0: number } | null = null;
  let dragMoved = false;
  let swallowClick = false;
  let hoverKey = '';
  let hoverTimer: ReturnType<typeof setTimeout> | null = null;
  // The empty board cell the dragged tile is currently aimed at, highlighted as a drop
  // target while carrying a tile over the board (Stage 17). Null over an occupied cell.
  let dropTarget = $state<{ row: number; col: number } | null>(null);

  let dragPointerId = -1;
  function beginDrag(src: DragSrc, e: PointerEvent) {
    downInfo = { src, x0: e.clientX, y0: e.clientY };
    dragMoved = false;
    dragPointerId = e.pointerId;
    window.addEventListener('pointermove', onWinMove);
    window.addEventListener('pointerup', onWinUp);
    window.addEventListener('pointerdown', onExtraPointer);
  }
  // A second finger touching down turns the gesture into a pinch (Board handles it), so any
  // drag started by the first finger — e.g. a pinch that began on a pending tile — is aborted.
  // The starting pointer's own event also bubbles here, so ignore it by id.
  function onExtraPointer(e: PointerEvent) {
    if (downInfo && e.pointerId !== dragPointerId) cancelDrag();
  }
  function cancelDrag() {
    window.removeEventListener('pointermove', onWinMove);
    window.removeEventListener('pointerup', onWinUp);
    window.removeEventListener('pointerdown', onExtraPointer);
    clearHover();
    downInfo = null;
    dragMoved = false;
    drag = null;
  }
  function onRackDown(e: PointerEvent, index: number) {
    if (!isMyTurn || busy) return;
    beginDrag({ from: 'rack', index }, e);
  }
  // A pending tile can be dragged back to the rack, but only on the unzoomed board: when
  // zoomed the one-finger gesture scrolls the board, so recall there is via double-tap.
  function onBoardDown(e: PointerEvent, row: number, col: number) {
    if (!isMyTurn || busy || zoomed) return;
    beginDrag({ from: 'board', row, col }, e);
  }
  function cellUnder(x: number, y: number): { row: number; col: number } | null {
    const el = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest('[data-cell]') as
      | HTMLElement
      | null;
    if (!el?.dataset.row || !el.dataset.col) return null;
    return { row: Number(el.dataset.row), col: Number(el.dataset.col) };
  }
  function clearHover() {
    if (hoverTimer) clearTimeout(hoverTimer);
    hoverTimer = null;
    hoverKey = '';
    dropTarget = null;
  }
  function onWinMove(e: PointerEvent) {
    if (!downInfo) return;
    if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
      dragMoved = true;
      const src = downInfo.src;
      const letter =
        src.from === 'rack' ? placement.rack[src.index] : pendingMap.get(`${src.row},${src.col}`)?.letter ?? '';
      drag = { letter, blank: letter === BLANK, x: e.clientX, y: e.clientY };
      // No zoom on drag start: the player may still change their mind. Holding the tile
      // over a cell for ~1s auto-zooms there (hover-hold below); a drop also zooms+centres.
    }
    if (!drag) return;
    drag = { ...drag, x: e.clientX, y: e.clientY };
    const c = cellUnder(e.clientX, e.clientY);
    // Highlight the aimed-at cell as a drop target, but only when it is free (no committed
    // or pending tile there).
    dropTarget = c && !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
    const ck = c ? `${c.row},${c.col}` : '';
    if (ck !== hoverKey) {
      hoverKey = ck;
      if (hoverTimer) clearTimeout(hoverTimer);
      hoverTimer =
        c && !zoomed
          ? setTimeout(() => {
              // Still holding the tile over this cell: magnify into it. Only the first
              // (zoom-in) hold centres; once zoomed we never move the board on hover.
              if (drag && isCoarse() && !zoomed) {
                focus = c;
                zoomed = true;
                telegramHaptic('light');
              }
            }, 1000)
          : null;
    }
  }
  function onWinUp(e: PointerEvent) {
    window.removeEventListener('pointermove', onWinMove);
    window.removeEventListener('pointerup', onWinUp);
    window.removeEventListener('pointerdown', onExtraPointer);
    clearHover();
    const di = downInfo;
    downInfo = null;
    if (drag && dragMoved && di) {
      drag = null;
      const onRack = !!(document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest('[data-rack]');
      const cell = cellUnder(e.clientX, e.clientY);
      if (di.src.from === 'rack' && cell) {
        attemptPlace(di.src.index, cell.row, cell.col);
      } else if (di.src.from === 'board' && onRack) {
        // Dropped a pending tile back onto the rack → recall it to its original slot.
        placement = recallAt(placement, di.src.row, di.src.col);
        recompute();
      }
      swallowClick = true;
      setTimeout(() => (swallowClick = false), 60);
    } else if (di && di.src.from === 'rack') {
      selected = selected === di.src.index ? null : di.src.index;
      drag = null;
    } else {
      drag = null;
    }
  }
  onDestroy(() => {
    window.removeEventListener('pointermove', onWinMove);
    window.removeEventListener('pointerup', onWinUp);
    window.removeEventListener('pointerdown', onExtraPointer);
    clearHover();
    telegramClosingConfirmation(false);
  });

  function onCell(row: number, col: number) {
    if (swallowClick) return;
    // A pending tile is recalled by a double-tap or by dragging it back to the rack, not
    // by a single tap (which recalled too easily — Stage 17).
    if (pendingMap.has(`${row},${col}`)) return;
    if (selected != null) {
      // A committed tile already sits here: keep the rack selection so a stray tap
      // on an occupied cell doesn't cancel placement — wait for an empty cell.
      if (board[row]?.[col]) return;
      attemptPlace(selected, row, col);
      selected = null;
    }
  }
  function onRecall(row: number, col: number) {
    placement = recallAt(placement, row, col);
    recompute();
  }
  function attemptPlace(index: number, row: number, col: number) {
    if (board[row]?.[col]) return;
    if (pendingMap.has(`${row},${col}`)) return;
    focus = { row, col };
    if (isCoarse() && !zoomed) zoomed = true;
    if (placement.rack[index] === BLANK) {
      blankPrompt = { rackIndex: index, row, col };
      return;
    }
    placement = place(placement, index, row, col);
    telegramHaptic('select');
    recompute();
  }
  function chooseBlank(letter: string) {
    if (!blankPrompt) return;
    placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
    blankPrompt = null;
    telegramHaptic('select');
    recompute();
  }

  let previewTimer: ReturnType<typeof setTimeout> | null = null;
  function recompute() {
    preview = null;
    if (previewTimer) clearTimeout(previewTimer);
    const sub = toSubmit(placement, dirOverride);
    if (!sub) return;
    previewTimer = setTimeout(async () => {
      try {
        preview = await gateway.evaluate(id, sub.dir, sub.tiles, variant);
      } catch {
        /* best-effort */
      }
    }, 250);
  }

  async function commit() {
    const sub = toSubmit(placement, dirOverride);
    if (!sub) return;
    busy = true;
    try {
      await gateway.submitPlay(id, sub.dir, sub.tiles, variant);
      telegramHaptic('success');
      zoomed = false;
      await load();
    } catch (e) {
      handleError(e);
    } finally {
      busy = false;
    }
  }
  function resetPlacement() {
    placement = reset(placement);
    preview = null;
    selected = null;
    dirOverride = undefined;
  }

  async function doPass() {
    busy = true;
    try {
      await gateway.pass(id);
      await load();
    } catch (e) {
      handleError(e);
    } finally {
      busy = false;
    }
  }
  async function doResign() {
    resignOpen = false;
    busy = true;
    try {
      await gateway.resign(id);
      await load();
    } catch (e) {
      handleError(e);
    } finally {
      busy = false;
    }
  }
  async function doHint() {
    try {
      const h = await gateway.hint(id);
      if (h.move.tiles.length && view) {
        placement = placementFromHint(h.move.tiles, view.rack);
        // Scroll the (zoomed) board to the hint's placement rather than the top-left:
        // focus the centre of the laid tiles' bounding box.
        const p = placement.pending;
        if (p.length) {
          const rows = p.map((tt) => tt.row);
          const cols = p.map((tt) => tt.col);
          focus = {
            row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
            col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
          };
        }
        if (isCoarse()) zoomed = true;
        view = { ...view, hintsRemaining: h.hintsRemaining };
        recompute();
      }
    } catch (e) {
      // The backend does not spend a hint when there is no move.
      if (e instanceof GatewayError && e.code === 'no_hint_available') {
        showToast(t('game.noHintOptions'), 'info');
      } else {
        handleError(e);
      }
    }
  }
  function shuffle() {
    if (placement.pending.length > 0) return;
    // Shuffle an index permutation, then apply it to both the letters and the slot ids so
    // each tile keeps its id as it flies to a new position (driving Rack's hop animation).
    const order = placement.rack.map((_, i) => i);
    for (let i = order.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [order[i], order[j]] = [order[j], order[i]];
    }
    rackIds = order.map((i) => rackIds[i] ?? i);
    placement = newPlacement(order.map((i) => placement.rack[i]));
    selected = null;
    shuffling = true;
    setTimeout(() => (shuffling = false), 600);
    // A short "shake": a few quick light taps rather than one.
    for (let i = 0; i < 4; i++) setTimeout(() => telegramHaptic('light'), i * 55);
  }
  function openExchange() {
    resetPlacement();
    exchangeSel = [];
    exchangeOpen = true;
  }
  function toggleExch(i: number) {
    exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
  }
  async function doExchange() {
    if (!view || exchangeSel.length === 0) return;
    const tiles = exchangeSel.map((i) => view!.rack[i]);
    exchangeOpen = false;
    busy = true;
    try {
      await gateway.exchange(id, tiles, variant);
      await load();
    } catch (e) {
      handleError(e);
    } finally {
      busy = false;
    }
  }

  function openCheck() {
    checkWord = '';
    checkResult = null;
    checkOpen = true;
  }
  function onCheckInput(e: Event) {
    checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
  }
  // Check is disabled while cooling down, for an already-checked word, or an out-of-range
  // length. The input filter already restricts to the variant's alphabet.
  function canCheck(): boolean {
    return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling);
  }
  async function runCheck() {
    if (!canCheck()) return;
    const w = checkWord.trim().toUpperCase();
    cooling = true;
    setTimeout(() => (cooling = false), 5000);
    try {
      const r = await gateway.checkWord(id, w, variant);
      // Key the cache and the displayed result on the upper-case word the player typed; the
      // server echoes the decoded concrete word in the solver's lower case.
      checkedWords.set(w, r.legal);
      checkResult = { word: w, legal: r.legal };
    } catch (e) {
      handleError(e);
    }
  }
  async function complain() {
    if (!checkResult) return;
    try {
      await gateway.complaint(id, checkResult.word, '');
      showToast(t('game.complaintSent'));
      checkOpen = false;
    } catch (e) {
      handleError(e);
    }
  }

  function openChat() {
    panel = 'chat';
    void loadChat();
  }
  async function sendChat(text: string) {
    try {
      messages = [...messages, await gateway.chatPost(id, text)];
    } catch (e) {
      handleError(e);
    }
  }
  async function nudge() {
    try {
      messages = [...messages, await gateway.nudge(id)];
    } catch (e) {
      handleError(e);
    }
  }

  function resultText(): string {
    if (!view) return '';
    const me = view.game.seats[view.seat];
    if (me?.isWinner) return t('game.won');
    return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
  }

  async function exportGcg() {
    try {
      await shareOrDownloadGcg(await gateway.exportGcg(id));
    } catch (e) {
      handleError(e);
    }
  }

  let requested = $state(new Set<string>());
  const noop = () => {};

  async function addFriend(accountId: string) {
    try {
      await gateway.friendRequest(accountId);
      requested = new Set([...requested, accountId]);
      showToast(t('friends.requestSent'));
    } catch (e) {
      handleError(e);
    }
  }

  const opponents = $derived(
    view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
  );

  // In a finished game the menu drops Check word and Drop game, gains Export GCG, and
  // an "add to friends" item flips to a disabled "request sent" once tapped.
  const menuItems = $derived([
    { label: t('game.history'), onclick: () => (historyOpen = true) },
    { label: t('game.chat'), onclick: openChat },
    ...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: openCheck }]),
    ...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
    ...(!app.profile?.isGuest
      ? opponents.map((s) =>
          requested.has(s.accountId)
            ? { label: t('game.requestSent'), onclick: noop, disabled: true }
            : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
        )
      : []),
    ...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
  ]);
</script>

<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
  {#snippet menu()}
    <Menu items={menuItems} />
  {/snippet}

  {#if view}
    <!-- svelte-ignore a11y_no_static_element_interactions -->
    <!-- svelte-ignore a11y_click_events_have_key_events -->
    <div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
      {#each view.game.seats as s (s.seat)}
        <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
          <div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
          <div class="sc">{s.score}</div>
        </div>
      {/each}
    </div>

    <div class="stage">
      {#if historyOpen}
        <div class="history">
          <ol>
            {#each moves as m, i (i)}
              <li>
                <span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
                <span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
                <span class="hs">{m.score} <span class="ht">({m.total})</span></span>
              </li>
            {/each}
            {#if moves.length === 0}<li class="hempty">—</li>{/if}
          </ol>
        </div>
      {/if}

      <!-- svelte-ignore a11y_no_static_element_interactions -->
      <!-- svelte-ignore a11y_click_events_have_key_events -->
      <div
        class="boardwrap"
        class:slid={historyOpen}
        onclick={() => historyOpen && (historyOpen = false)}
      >
        <Board
          {board}
          {premium}
          pending={pendingMap}
          {highlight}
          {flash}
          centre={ctr}
          {zoomed}
          {variant}
          labelMode={app.boardLabels}
          lines={app.boardLines}
          locale={app.locale}
          {focus}
          {dropTarget}
          oncell={onCell}
          ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
          onrecall={onRecall}
          onpenddown={onBoardDown}
        />
      </div>
    </div>

    <div class="status">
      <span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
      {#if gameOver}
        <strong class="over">{t('game.over')} — {resultText()}</strong>
      {:else}
        <span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
      {/if}
      <span class="scores">
        {#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
      </span>
    </div>

    <!-- The footer is drawn even when the game is over (rack + tab bar), but inert:
         a finished game shows the final rack greyed out and the controls disabled. -->
    <div class="rack-row" class:inert={gameOver}>
      <div class="rack-wrap">
        <Rack slots={rackSlots} {variant} {selected} shuffling={shuffling && !app.reduceMotion} ondown={onRackDown} />
      </div>
      {#if !gameOver && placement.pending.length > 0}
        <button class="make" onclick={commit} disabled={busy || (preview !== null && !preview.legal)} aria-label={t('game.makeMove')}>✅</button>
      {/if}
    </div>
  {:else}
    <p class="loading">{t('common.loading')}</p>
  {/if}

  {#snippet tabbar()}
    {#if view}
      <TabBar>
        <button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
          <span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
        </button>
        <HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
          {#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
          {#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
        </HoldConfirm>
        <HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
          {#snippet trigger()}
            <span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
            <span class="lbl">{t('game.hint')}</span>
          {/snippet}
          {#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
        </HoldConfirm>
        {#if placement.pending.length > 0}
          <button class="tab" disabled={busy} onclick={resetPlacement}>
            <span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
          </button>
        {:else}
          <button class="tab" disabled={busy || gameOver} onclick={shuffle}>
            <span class="sq">🔀</span>
          </button>
        {/if}
      </TabBar>
    {/if}
  {/snippet}
</Screen>

{#if drag}
  <div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
    <span>{drag.blank ? '' : drag.letter}</span>
  </div>
{/if}

{#if blankPrompt}
  <Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
    <div class="alpha">
      {#each alphabetLetters(variant) as ch (ch)}
        <button onclick={() => chooseBlank(ch)}>{ch}</button>
      {/each}
    </div>
  </Modal>
{/if}

{#if exchangeOpen && view}
  <Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
    <div class="exch">
      {#each view.rack as letter, i (i)}
        <button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
          {letter === BLANK ? '?' : letter}
        </button>
      {/each}
    </div>
    <button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
      {t('game.exchangeConfirm', { n: exchangeSel.length })}
    </button>
  </Modal>
{/if}

{#if checkOpen}
  <Modal title={t('game.checkWord')} overlayKeyboard onclose={() => (checkOpen = false)}>
    <div class="check">
      <input
        value={checkWord}
        oninput={onCheckInput}
        onkeydown={(e) => e.key === 'Enter' && runCheck()}
        placeholder={t('game.checkWordPrompt')}
      />
      <button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
    </div>
    {#if checkResult}
      <p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
        {checkResult.legal
          ? t('game.wordLegal', { word: checkResult.word })
          : t('game.wordIllegal', { word: checkResult.word })}
      </p>
      <button class="complain" onclick={complain}>{t('game.complain')}</button>
    {/if}
  </Modal>
{/if}

{#if resignOpen}
  <Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
    <div class="confirm-row">
      <button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
      <button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
    </div>
  </Modal>
{/if}

{#if panel === 'chat'}
  <Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
    <Chat {messages} myId={app.session?.userId ?? ''} {busy} canNudge={!isMyTurn && !nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
  </Modal>
{/if}

<style>
  .scoreboard {
    display: flex;
    flex: none;
    gap: 6px;
    padding: 8px var(--pad);
    background: var(--bg-elev);
    cursor: pointer;
  }
  .seat {
    flex: 1;
    text-align: center;
    padding: 5px 4px;
    border-radius: var(--radius-sm);
    /* inactive seats recede: they blend into the bar, slightly sunk */
    background: transparent;
    box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.18);
  }
  .seat .nm {
    color: var(--text-muted);
  }
  .seat.turn {
    /* the active seat pops: a raised, accented chip lifted clear of the bar */
    background: var(--surface-2);
    box-shadow:
      0 2px 6px rgba(0, 0, 0, 0.3),
      -3px 0 6px -2px rgba(0, 0, 0, 0.26),
      3px 0 6px -2px rgba(0, 0, 0, 0.26);
    position: relative;
    z-index: 1;
  }
  .seat.turn .nm {
    color: var(--accent);
  }
  .seat.win .sc {
    color: var(--ok);
  }
  .nm {
    font-size: 0.8rem;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .sc {
    font-weight: 700;
    font-variant-numeric: tabular-nums;
  }
  .stage {
    position: relative;
    /* The board is the only part that scrolls vertically when the game does not fit;
       the score bar, status, rack and tab bar stay put (#9). */
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overflow-x: hidden;
  }
  .history {
    position: absolute;
    inset: 0 0 auto 0;
    z-index: 2;
    /* A fixed-height drawer matching the board's slid offset, so the bottom border
       and its shadow pin to the board immediately instead of tracking the table as
       moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
       column does not jump left/right when the list overflows. */
    height: 62%;
    overflow: auto;
    scrollbar-gutter: stable;
    background: var(--surface-2);
    box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
    border-bottom: 1px solid var(--border);
  }
  .history ol {
    margin: 0;
    padding: 8px 14px;
    list-style: decimal;
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .history li {
    display: flex;
    justify-content: space-between;
    gap: 10px;
    font-size: 0.9rem;
  }
  .hp {
    color: var(--text-muted);
  }
  .ha {
    flex: 1;
    text-align: center;
  }
  .hs {
    font-variant-numeric: tabular-nums;
    font-weight: 600;
  }
  .ht {
    color: var(--text-muted);
    font-weight: 400;
    font-size: 0.85em;
  }
  .hempty {
    justify-content: center;
    color: var(--text-muted);
  }
  .boardwrap {
    padding: 6px;
    transition: transform 0.3s ease;
  }
  .boardwrap.slid {
    transform: translateY(62%);
  }
  .status {
    display: flex;
    flex: none;
    align-items: center;
    justify-content: space-between;
    padding: 2px var(--pad) 6px;
    color: var(--text-muted);
    font-size: 0.85rem;
  }
  .turn-ind {
    font-weight: 600;
    color: var(--text);
  }
  .over {
    color: var(--accent);
  }
  .scores {
    font-weight: 600;
    color: var(--ok);
    min-width: 64px;
    text-align: right;
  }
  .rack-row {
    display: flex;
    flex: none;
    gap: 8px;
    align-items: stretch;
    padding: 0 var(--pad) 6px;
  }
  .rack-row.inert {
    pointer-events: none;
    opacity: 0.55;
  }
  .rack-wrap {
    flex: 1;
    min-width: 0;
  }
  /* A borderless icon button (like the tab bar), not a filled accent button — and disabled
     while the pending word is known to be illegal (Stage 17). */
  .make {
    min-width: 56px;
    background: none;
    color: var(--text);
    border: none;
    display: grid;
    place-items: center;
    font-size: 1.8rem;
  }
  .make:disabled {
    opacity: 0.4;
  }
  .pop {
    padding: 9px 14px;
    border: none;
    background: none;
    color: var(--text);
    border-radius: var(--radius-sm);
    font-weight: 500;
    text-align: left;
  }
  .pop:hover {
    background: var(--surface-2);
  }
  .badge {
    position: absolute;
    top: -3px;
    right: -3px;
    font-size: 0.68rem;
    font-weight: 700;
    background: var(--accent);
    color: var(--accent-text);
    border-radius: 999px;
    min-width: 15px;
    padding: 0 3px;
    line-height: 1.4;
    text-align: center;
  }
  .loading {
    text-align: center;
    color: var(--text-muted);
    padding: 40px;
  }
  .ghost {
    position: fixed;
    width: 40px;
    height: 40px;
    transform: translate(-50%, -50%);
    background: var(--tile-pending);
    color: var(--tile-text);
    border-radius: 5px;
    display: grid;
    place-items: center;
    font-weight: 700;
    font-size: 1.3rem;
    box-shadow: var(--shadow);
    pointer-events: none;
    z-index: 60;
  }
  .alpha {
    display: grid;
    grid-template-columns: repeat(6, 1fr);
    gap: 6px;
  }
  .alpha button {
    aspect-ratio: 1;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    border-radius: var(--radius-sm);
    font-weight: 700;
  }
  .exch {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 6px;
    margin-bottom: 12px;
  }
  .etile {
    aspect-ratio: 1;
    border: 1px solid var(--border);
    background: var(--tile-bg);
    color: var(--tile-text);
    border-radius: 5px;
    font-weight: 700;
  }
  .etile.sel {
    outline: 3px solid var(--accent);
    outline-offset: -3px;
  }
  .confirm {
    width: 100%;
    padding: 11px;
    background: var(--accent);
    color: var(--accent-text);
    border: none;
    border-radius: var(--radius-sm);
    font-weight: 700;
  }
  .confirm:disabled {
    opacity: 0.5;
  }
  .check {
    display: flex;
    gap: 6px;
  }
  .check input {
    flex: 1;
    padding: 10px;
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    background: var(--bg);
    color: var(--text);
    text-transform: uppercase;
  }
  .check button {
    padding: 10px 12px;
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    border-radius: var(--radius-sm);
  }
  .ok {
    color: var(--ok);
  }
  .bad {
    color: var(--danger);
  }
  .complain {
    background: none;
    border: none;
    color: var(--accent);
    padding: 4px 0;
  }
  .confirm-row {
    display: flex;
    gap: 8px;
  }
  .confirm-row button {
    flex: 1;
    padding: 11px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border);
    background: var(--surface);
    color: var(--text);
    font-weight: 600;
  }
  .danger {
    background: var(--danger) !important;
    color: #fff !important;
    border-color: var(--danger) !important;
  }
</style>
