<script lang="ts">
  import { untrack } from 'svelte';
  import type { BoardCell } from '../lib/board';
  import type { Premium } from '../lib/premiums';
  import { valueForLetter } from '../lib/alphabet';
  import type { Variant } from '../lib/model';
  import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
  import type { Locale } from '../lib/i18n/catalog';

  let {
    board,
    premium,
    pending,
    highlight,
    flash,
    centre,
    zoomed,
    variant,
    labelMode,
    lines,
    locale,
    focus,
    dropTarget,
    oncell,
    ontogglezoom,
    onrecall,
    onpenddown,
  }: {
    board: (BoardCell | null)[][];
    premium: Premium[][];
    pending: Map<string, { letter: string; blank: boolean }>;
    highlight: Set<string>;
    flash: boolean;
    centre: { row: number; col: number };
    zoomed: boolean;
    variant: Variant;
    labelMode: BoardLabelMode;
    /** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
    lines: boolean;
    locale: Locale;
    focus: { row: number; col: number } | null;
    /** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
    dropTarget: { row: number; col: number } | null;
    oncell: (row: number, col: number) => void;
    ontogglezoom: (row: number, col: number) => void;
    /** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
    onrecall: (row: number, col: number) => void;
    /** Pointer-down on a pending cell, to start dragging that tile back to the rack. */
    onpenddown: (e: PointerEvent, row: number, col: number) => void;
  } = $props();

  const Z = 1.85;
  const z = $derived(zoomed ? Z : 1);
  const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };

  let viewport = $state<HTMLElement>();

  // Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
  // works in every browser. The pan is interpolated toward a PRE-CLAMPED final scroll as
  // the board's real width grows (zoom-in) or shrinks (zoom-out), so it magnifies evenly
  // from A to B in one motion instead of chasing a per-frame target that the scroll bounds
  // clamp — which made the board lurch one way and then snap back near the edges/centre.
  // It runs only on a zoom toggle (`zoomed`); changing `focus` while already zoomed does
  // not recentre, so placing a 2nd+ tile or hovering a dragged tile never jumps the board.
  $effect(() => {
    const on = zoomed;
    const vp = viewport;
    if (!vp) return;
    const f = untrack(() => focus);
    const clientW = vp.clientWidth;
    const clientH = vp.clientHeight;
    if (clientW === 0) return;
    const fitW = clientW; // board width at scale 1 (fills the viewport)
    const fullW = clientW * Z; // board width at full zoom
    const startSL = vp.scrollLeft;
    const startST = vp.scrollTop;
    let finalSL = 0;
    let finalST = 0;
    if (on && f) {
      const cell = fullW / 15;
      finalSL = Math.max(0, Math.min((f.col + 0.5) * cell - clientW / 2, fullW - clientW));
      finalST = Math.max(0, Math.min((f.row + 0.5) * cell - clientH / 2, fullW - clientH));
    }
    const fromW = on ? fitW : fullW; // board width when this transition begins
    const toW = on ? fullW : fitW;
    let raf = requestAnimationFrame(function tick() {
      const curW = vp.scrollWidth || fromW;
      let prog = (curW - fromW) / (toW - fromW);
      prog = prog < 0 ? 0 : prog > 1 ? 1 : prog;
      vp.scrollLeft = startSL + (finalSL - startSL) * prog;
      vp.scrollTop = startST + (finalST - startST) * prog;
      if (prog < 1) raf = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(raf);
  });

  // Pinch zoom: a two-finger spread zooms in toward the pinch midpoint, a pinch
  // close zooms out. preventDefault fires only for two touches, so the one-finger native
  // scroll of the zoomed board is left untouched. It maps to the same two-state zoom as
  // double-tap, toggling toward the midpoint cell.
  $effect(() => {
    const vp = viewport;
    if (!vp) return;
    let startDist = 0;
    let acted = false;
    const span = (t: TouchList) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
    const midCell = (t: TouchList) => {
      const x = (t[0].clientX + t[1].clientX) / 2;
      const y = (t[0].clientY + t[1].clientY) / 2;
      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) };
    };
    const onStart = (e: TouchEvent) => {
      if (e.touches.length === 2) {
        startDist = span(e.touches);
        acted = false;
      }
    };
    const onMove = (e: TouchEvent) => {
      if (e.touches.length !== 2 || startDist === 0) return;
      e.preventDefault(); // claim the two-finger gesture; one finger still scrolls natively
      if (acted) return;
      const ratio = span(e.touches) / startDist;
      if (ratio > 1.25 && !zoomed) {
        const c = midCell(e.touches);
        if (c) {
          acted = true;
          ontogglezoom(c.row, c.col);
        }
      } else if (ratio < 0.8 && zoomed) {
        const c = midCell(e.touches) ?? centre;
        acted = true;
        ontogglezoom(c.row, c.col);
      }
    };
    const onEnd = (e: TouchEvent) => {
      if (e.touches.length < 2) {
        startDist = 0;
        acted = false;
      }
    };
    vp.addEventListener('touchstart', onStart, { passive: true });
    vp.addEventListener('touchmove', onMove, { passive: false });
    vp.addEventListener('touchend', onEnd);
    vp.addEventListener('touchcancel', onEnd);
    return () => {
      vp.removeEventListener('touchstart', onStart);
      vp.removeEventListener('touchmove', onMove);
      vp.removeEventListener('touchend', onEnd);
      vp.removeEventListener('touchcancel', onEnd);
    };
  });

  // Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
  // it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
  // toward a cell the held tile hovers over (handled in Game), so the one-finger native
  // scroll of the zoomed board is never hijacked.
  let lastTap = 0;
  let lastCell = '';
  function onTap(row: number, col: number) {
    const now = Date.now();
    const k = key(row, col);
    // A double-tap counts only when it lands twice on the same cell, so quick taps across
    // different cells don't coalesce into a stray recall/zoom.
    if (k === lastCell && now - lastTap < 300) {
      lastTap = 0;
      lastCell = '';
      if (pending.has(k)) onrecall(row, col);
      else ontogglezoom(row, col);
      return;
    }
    lastTap = now;
    lastCell = k;
    oncell(row, col);
  }

  const key = (r: number, c: number) => `${r},${c}`;
</script>

<div class="viewport" class:zoomed bind:this={viewport}>
  <div class="scaler" style="--z: {z};">
    <div class="grid" class:gridless={!lines}>
      {#each board as rowCells, r (r)}
        {#each rowCells as cell, c (c)}
          {@const p = pending.get(key(r, c))}
          {@const letter = cell?.letter ?? p?.letter ?? ''}
          {@const blank = cell?.blank ?? p?.blank ?? false}
          {@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
          <button
            class="cell {premClass[premium[r][c]]}"
            class:filled={!!cell}
            class:pending={!!p && !cell}
            class:hl={!!cell && highlight.has(key(r, c)) && !flash}
            class:flash={!!cell && flash && highlight.has(key(r, c))}
            class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
            class:droptarget={dropTarget?.row === r && dropTarget?.col === c}
            data-cell
            data-row={r}
            data-col={c}
            onclick={() => onTap(r, c)}
            onpointerdown={(e) => { if (!!p && !cell) onpenddown(e, r, c); }}
          >
            {#if letter}
              <span class="letter">{letter}</span>
              {#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
            {:else if r === centre.row && c === centre.col}
              <span class="star">★</span>
            {:else if bl?.kind === 'single'}
              <span class="b1">{bl.text}</span>
            {:else if bl?.kind === 'split'}
              <span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
            {/if}
          </button>
        {/each}
      {/each}
    </div>
  </div>
</div>

<style>
  .viewport {
    width: 100%;
    aspect-ratio: 1;
    overflow: hidden;
    background: var(--board-bg);
    border-radius: var(--radius-sm);
  }
  .viewport.zoomed {
    overflow: auto;
    /* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
       board cannot be dragged past its content into empty space — it just stops at the edge. */
    overscroll-behavior: none;
  }
  /* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
     — a magnifying-glass zoom. */
  .scaler {
    width: calc(100% * var(--z));
    transition: width 0.25s ease;
    container-type: inline-size;
  }
  .grid {
    display: grid;
    grid-template-columns: repeat(15, 1fr);
    gap: 1px;
    background: var(--cell-line);
    padding: 1px;
  }
  .cell {
    position: relative;
    aspect-ratio: 1;
    border: none;
    border-radius: 1px;
    background: var(--cell-bg);
    color: var(--prem-text);
    /* No mobile tap flash on a cell tap (parity with the web click; the only intentional
       cell animation is the last-word .flash highlight). */
    -webkit-tap-highlight-color: transparent;
    padding: 0;
    overflow: hidden;
    font-size: 0;
  }
  .cell.tw {
    background: var(--prem-tw);
  }
  .cell.dw {
    background: var(--prem-dw);
  }
  .cell.tl {
    background: var(--prem-tl);
  }
  .cell.dl {
    background: var(--prem-dl);
  }
  .cell.filled,
  .cell.pending {
    background: var(--tile-bg);
    color: var(--tile-text);
    box-shadow: inset 0 -2px 0 var(--tile-edge);
  }
  .cell.pending {
    background: var(--tile-pending);
    /* The placed tile owns the pointer so it can be dragged to relocate it (even on the zoomed
       board) instead of the touch starting a board pan. */
    touch-action: none;
  }
  /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
     reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
     get rounded corners and a soft right-side shadow so adjacent gapless tiles still read
     as separate pieces. */
  .grid.gridless {
    gap: 0;
    padding: 0;
    background: var(--board-bg);
  }
  .grid.gridless .cell {
    border-radius: 0;
  }
  .grid.gridless .cell.dark {
    background: color-mix(in srgb, var(--cell-bg) 88%, #000);
  }
  .grid.gridless .cell.filled,
  .grid.gridless .cell.pending {
    border-radius: 4px;
    box-shadow:
      inset 0 -2px 0 var(--tile-edge),
      2px 0 3px -1px rgba(0, 0, 0, 0.4);
  }
  .cell.droptarget {
    /* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
       target reads clearly while dragging without obscuring the bonus label underneath. */
    box-shadow: inset 0 0 0 2px var(--accent);
    background: color-mix(in srgb, var(--accent) 18%, var(--cell-bg));
  }
  .cell.hl {
    background: var(--tile-recent);
    /* The bottom edge goes darker than the highlighted fill (not lighter, as the plain
       --tile-edge would), so the tile still reads as raised. */
    box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
  }
  .cell.flash {
    /* Two flashes to draw the eye, then settle back to normal so it does not distract. */
    animation: tileflash 1s ease-in-out 2;
  }
  @keyframes tileflash {
    0%,
    100% {
      background: var(--tile-bg);
      box-shadow: inset 0 -2px 0 var(--tile-edge);
    }
    50% {
      background: var(--tile-recent);
      box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.32);
    }
  }
  /* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
     the board grows on zoom (relatively smaller, never overflowing). */
  .letter {
    position: absolute;
    top: 5%;
    left: 8%;
    font-size: 4.2cqw;
    font-weight: 700;
    line-height: 1;
  }
  .val {
    position: absolute;
    right: 5%;
    bottom: 3%;
    font-size: 2.4cqw;
    font-weight: 600;
  }
  .star {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    font-size: 3.6cqw;
    opacity: 0.7;
  }
  .b1 {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    font-size: 2.7cqw;
    font-weight: 600;
    opacity: 0.9;
  }
  .bsplit {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    line-height: 1.05;
    opacity: 0.92;
    overflow: hidden;
    padding: 0 1px;
  }
  .bt {
    font-size: 1.7cqw;
    font-weight: 600;
  }
  .bb {
    font-size: 1.9cqw;
    font-weight: 700;
    white-space: nowrap;
  }
</style>
