/* Shared card visuals + cuttlefish + small UI primitives */

const SUIT_GLYPHS = ['♣', '♦', '♥', '♠'];
const SUIT_NAMES = ['clubs', 'diamonds', 'hearts', 'spades'];
const RANK_LABELS = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];

// Pip layouts (Ace..Ten). Each is an array of {x, y} 0..1 within the pip area.
const PIP_LAYOUTS = {
  1: [[0.5, 0.5]],
  2: [[0.5, 0.18], [0.5, 0.82]],
  3: [[0.5, 0.18], [0.5, 0.5], [0.5, 0.82]],
  4: [[0.25, 0.18], [0.75, 0.18], [0.25, 0.82], [0.75, 0.82]],
  5: [[0.25, 0.18], [0.75, 0.18], [0.5, 0.5], [0.25, 0.82], [0.75, 0.82]],
  6: [[0.25, 0.18], [0.75, 0.18], [0.25, 0.5], [0.75, 0.5], [0.25, 0.82], [0.75, 0.82]],
  7: [[0.25, 0.18], [0.75, 0.18], [0.5, 0.34], [0.25, 0.5], [0.75, 0.5], [0.25, 0.82], [0.75, 0.82]],
  8: [[0.25, 0.18], [0.75, 0.18], [0.5, 0.34], [0.25, 0.5], [0.75, 0.5], [0.5, 0.66], [0.25, 0.82], [0.75, 0.82]],
  9: [[0.25, 0.16], [0.75, 0.16], [0.25, 0.36], [0.75, 0.36], [0.5, 0.5], [0.25, 0.64], [0.75, 0.64], [0.25, 0.84], [0.75, 0.84]],
  10: [[0.25, 0.14], [0.75, 0.14], [0.25, 0.32], [0.75, 0.32], [0.5, 0.23], [0.5, 0.77], [0.25, 0.68], [0.75, 0.68], [0.25, 0.86], [0.75, 0.86]],
};

function PlayingCard({ card, w = 84, faceDown = false, selected = false, dim = false, glow, style, onClick, onMouseEnter, onMouseLeave, attachedJacks = [], compact = false, theme = 'lagoon' }) {
  const h = w * 1.4;
  if (theme === 'arcade') {
    const Pixel = window.ArcadePixelCard;
    if (Pixel) {
      return (
        <Pixel
          card={card} w={w} faceDown={faceDown} selected={selected} dim={dim}
          glow={glow} style={style} onClick={onClick}
          onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}
          attachedJacks={attachedJacks} compact={compact}
        />
      );
    }
  }
  const isRed = card && (card.suit === 1 || card.suit === 2);
  const ranNum = card ? (card.rank + 1) : 0;
  const isCourt = card && card.rank >= 10;
  const isAce = card && card.rank === 0;

  const isArcade = theme === 'arcade';
  const cardBg = isArcade ? '#1c2440' : (theme === 'tideline' ? '#fcfaf5' : '#fefdf8');
  const ink = isArcade ? (isRed ? '#ff3b8b' : '#00f0c8') : (isRed ? '#c2453d' : '#1e2a2f');
  const subInk = isArcade ? (isRed ? '#ff7ab0' : '#7be8d4') : (isRed ? '#d97a73' : '#5a6a72');

  const baseStyle = {
    width: w,
    height: h,
    borderRadius: isArcade ? 2 : w * 0.10,
    background: cardBg,
    boxShadow: isArcade
      ? (selected
          ? `0 0 0 2px #0a0e1a, 0 0 0 4px #00f0c8, 0 0 18px #00f0c8, 0 -10px 0 0 transparent`
          : `0 0 0 2px #0a0e1a, 0 0 0 3px ${ink}, 0 0 12px ${ink}55`)
      : (selected
          ? `0 0 0 3px ${theme === 'tideline' ? '#7fc8b6' : '#f3a682'}, 0 18px 30px -10px rgba(40,30,20,0.35)`
          : '0 6px 14px -4px rgba(40,30,20,0.25), 0 1px 0 rgba(255,255,255,0.6) inset'),
    position: 'relative',
    cursor: onClick ? 'pointer' : 'default',
    transition: 'transform 220ms cubic-bezier(.2,.8,.2,1), box-shadow 200ms, opacity 200ms',
    transform: selected ? 'translateY(-14px)' : 'translateY(0)',
    opacity: dim ? 0.45 : 1,
    color: ink,
    fontFamily: isArcade ? '"VT323", "Courier New", monospace' : '"Fraunces", Georgia, serif',
    userSelect: 'none',
    overflow: 'hidden',
    ...(style || {}),
  };

  if (faceDown) {
    return (
      <div style={baseStyle} onClick={onClick}>
        <CardBack w={w} theme={theme} />
      </div>
    );
  }

  if (!card) return <div style={baseStyle} />;

  return (
    <div style={baseStyle} onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
      {/* paper / scanline texture */}
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        background: isArcade
          ? 'repeating-linear-gradient(to bottom, rgba(255,255,255,0) 0px, rgba(255,255,255,0) 2px, rgba(0,0,0,0.18) 2px, rgba(0,0,0,0.18) 3px)'
          : 'radial-gradient(140% 90% at 0% 0%, rgba(255,255,255,0.5), transparent 60%), radial-gradient(120% 90% at 100% 100%, rgba(0,0,0,0.04), transparent 50%)',
      }} />
      {glow && (
        <div style={{
          position: 'absolute', inset: -3, borderRadius: w * 0.12,
          boxShadow: `0 0 0 3px ${glow}, 0 0 24px ${glow}`,
          pointerEvents: 'none', animation: 'pulseGlow 1.6s ease-in-out infinite',
        }} />
      )}
      {/* Top-left index */}
      <div style={{
        position: 'absolute', top: w * 0.07, left: w * 0.09,
        fontSize: w * 0.22, lineHeight: 1, fontWeight: 600, letterSpacing: '-0.03em',
      }}>
        <div>{RANK_LABELS[card.rank]}</div>
        <div style={{ fontSize: w * 0.20, marginTop: w * 0.02 }}>{SUIT_GLYPHS[card.suit]}</div>
      </div>
      {/* Bottom-right index, rotated */}
      <div style={{
        position: 'absolute', bottom: w * 0.07, right: w * 0.09,
        fontSize: w * 0.22, lineHeight: 1, fontWeight: 600, transform: 'rotate(180deg)', letterSpacing: '-0.03em',
      }}>
        <div>{RANK_LABELS[card.rank]}</div>
        <div style={{ fontSize: w * 0.20, marginTop: w * 0.02 }}>{SUIT_GLYPHS[card.suit]}</div>
      </div>

      {/* Center — parallax detail layer. Reads --rx/--ry/--hover set by
          TiltCard (if present) so the artwork rotates harder than the card
          frame, giving real perceived depth. With no wrapper, the vars
          default to 0 and this layer sits flat. */}
      {!compact && (
        <div
          className="cuttle-card-detail"
          style={{
            position: 'absolute',
            inset: `${w*0.32}px ${w*0.22}px ${w*0.32}px ${w*0.22}px`,
            transform:
              'translateZ(calc(var(--hover, 0) * 8px)) ' +
              'rotateX(calc(var(--rx, 0deg) * 0.4)) ' +
              'rotateY(calc(var(--ry, 0deg) * 0.4))',
            transformStyle: 'preserve-3d',
            willChange: 'transform',
          }}
        >
          {isCourt ? <CourtArt rank={card.rank} suit={card.suit} ink={ink} subInk={subInk} w={w} theme={theme} /> :
            <PipsArt n={ranNum} suit={card.suit} ink={ink} />}
        </div>
      )}

      {/* attached jack indicator */}
      {attachedJacks.length > 0 && (
        <div style={{
          position: 'absolute', top: w*0.04, right: w*0.04,
          width: w*0.28, height: w*0.28, borderRadius: '50%',
          background: theme === 'tideline' ? '#7fc8b6' : '#f3a682',
          color: '#fff', fontFamily: 'Inter, sans-serif',
          fontSize: w*0.16, fontWeight: 700,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          boxShadow: '0 2px 4px rgba(0,0,0,0.18)',
        }}>J{attachedJacks.length > 1 ? attachedJacks.length : ''}</div>
      )}
    </div>
  );
}

function PipsArt({ n, suit, ink }) {
  const layout = PIP_LAYOUTS[n];
  if (!layout) return null;
  const glyph = SUIT_GLYPHS[suit];
  return (
    <div style={{ position: 'relative', width: '100%', height: '100%' }}>
      {layout.map(([x, y], i) => {
        const flip = y > 0.5 && [3,4,5,6,7,8,9,10].includes(n);
        return (
          <div key={i} style={{
            position: 'absolute', left: `${x*100}%`, top: `${y*100}%`,
            transform: `translate(-50%, -50%) ${flip ? 'rotate(180deg)' : ''}`,
            color: ink, fontSize: '1.5em', lineHeight: 1,
          }}>{glyph}</div>
        );
      })}
    </div>
  );
}

function CourtArt({ rank, suit, ink, subInk, w, theme }) {
  // Rank 10 = Jack, 11 = Queen, 12 = King. Stylized geometric placeholder.
  const letter = ['J','Q','K'][rank - 10];
  const isArcade = theme === 'arcade';
  if (isArcade) {
    return (
      <div style={{
        position: 'relative', width: '100%', height: '100%',
        background: 'rgba(0, 240, 200, 0.08)',
        border: `2px solid ${ink}`,
        boxShadow: `inset 0 0 8px ${ink}66`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        flexDirection: 'column',
      }}>
        <div style={{
          fontSize: w * 0.55, fontFamily: '"VT323", monospace', fontWeight: 400,
          color: ink, lineHeight: 1,
          textShadow: `0 0 8px ${ink}`,
        }}>{letter}</div>
        <div style={{ fontSize: w * 0.18, color: subInk, marginTop: w*0.02 }}>{SUIT_GLYPHS[suit]}</div>
      </div>
    );
  }
  const accent = rank === 10 ? '#f3a682' : rank === 11 ? '#e6b4a3' : '#cfa46b';
  return (
    <div style={{
      position: 'relative', width: '100%', height: '100%',
      background: `linear-gradient(180deg, ${accent}33 0%, ${accent}10 100%)`,
      borderRadius: w * 0.04,
      border: `1px solid ${ink}22`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      flexDirection: 'column',
    }}>
      <div style={{
        fontSize: w * 0.55, fontFamily: '"Fraunces", serif', fontWeight: 500,
        color: ink, lineHeight: 1, fontStyle: 'italic',
      }}>{letter}</div>
      <div style={{ fontSize: w * 0.18, color: subInk, marginTop: w*0.05 }}>{SUIT_GLYPHS[suit]}</div>
    </div>
  );
}

function CardBack({ w, theme }) {
  if (theme === 'arcade') {
    return (
      <div style={{
        position: 'absolute', inset: w*0.05, borderRadius: 0,
        background: '#0a0e1a',
        border: '2px solid #ff3b8b',
        boxShadow: 'inset 0 0 0 2px #00f0c8, inset 0 0 12px rgba(255,59,139,0.5)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <div style={{
          fontFamily: '"VT323", monospace',
          fontSize: w * 0.5,
          color: '#ff3b8b',
          textShadow: '0 0 8px #ff3b8b, 0 0 16px #ff3b8b',
          lineHeight: 1,
        }}>C</div>
      </div>
    );
  }
  const a = theme === 'tideline' ? '#5fa893' : '#e07a5f';
  const b = theme === 'tideline' ? '#3d7a6a' : '#b35637';
  return (
    <div style={{
      position: 'absolute', inset: w*0.05, borderRadius: w*0.07,
      background: `repeating-linear-gradient(45deg, ${a} 0 ${w*0.06}px, ${b} ${w*0.06}px ${w*0.12}px)`,
      border: `2px solid ${theme === 'tideline' ? '#fefdf8' : '#fefdf8'}`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    }}>
      <div style={{
        width: w * 0.5, height: w * 0.5, borderRadius: '50%',
        background: theme === 'tideline' ? '#fcfaf5' : '#fefdf8',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: '"Fraunces", serif', fontSize: w*0.28, fontStyle: 'italic',
        color: a,
      }}>c</div>
    </div>
  );
}

// ─── Pixel Cuttlefish ──────────────────────────────────────────────────────────
// 16x16 grid drawn as box-shadows. Mood: 'idle' | 'thinking' | 'smug' | 'concede' | 'happy'

const CF_COLORS = {
  body: '#a78bfa',
  bodyDark: '#7c5cd8',
  bodyHL: '#d4c4ff',
  eye: '#ffffff',
  pupil: '#1e1b30',
  blush: '#ffb3b3',
  fin: '#c8b3fb',
  sweat: '#7fd0ff',
  heart: '#ff5b8a',
  spark: '#ffd23f',
  angry: '#c2453d',
  tongue: '#ff7ab0',
};

const CUTTLE_MOODS = [
  'idle', 'thinking', 'smug', 'concede', 'happy',
  'surprised', 'angry', 'annoyed', 'scheming', 'shocked',
  'embarrassed', 'sleepy', 'wink', 'focused', 'suspicious',
  'determined', 'defeated', 'excited', 'confused', 'nervous',
  'smitten', 'proud',
];

function makeCuttleGrid(mood) {
  // 16x16. Returns 2D array of color codes; '.' = transparent.
  const g = Array.from({ length: 16 }, () => Array(16).fill('.'));

  // Body silhouette (mantle on top, tentacles below)
  const body = [
    '................',
    '.....DDDDDD.....',
    '...DDBBBBBBDD...',
    '..DBBBBBBBBBBD..',
    '.DBBBBBBBBBBBBD.',
    '.DBBBHBBBBHBBBD.',
    '.DBBHHBBBBHHBBD.',
    '.DBBBBBBBBBBBBD.',
    '.DBBBBBBBBBBBBD.',
    '.DBBBBBBBBBBBBD.',
    '..DBBBBBBBBBBD..',
    '...DDBBBBBBDD...',
    '....D.DDDD.D....',
    '....D.D..D.D....',
    '...DD.D..D.DD...',
    '..DD..D..D..DD..',
  ];
  for (let y = 0; y < 16; y++) {
    for (let x = 0; x < 16; x++) {
      const c = body[y][x];
      if (c === 'D') g[y][x] = CF_COLORS.bodyDark;
      else if (c === 'B') g[y][x] = CF_COLORS.body;
      else if (c === 'H') g[y][x] = CF_COLORS.bodyHL;
    }
  }

  // ── helpers ──────────────────────────────────────────────────────────────
  const eR = 5;            // eye top row
  const L = 4, R = 9;      // eye left columns
  const set = (y, x, c) => { if (y >= 0 && y < 16 && x >= 0 && x < 16) g[y][x] = c; };
  const fill2x2 = (y, x, c) => { set(y, x, c); set(y, x+1, c); set(y+1, x, c); set(y+1, x+1, c); };
  const blush = (color = CF_COLORS.blush) => { set(7, 2, color); set(7, 13, color); };
  const wideBlush = (color = CF_COLORS.blush) => {
    set(7, 2, color); set(7, 3, color); set(8, 2, color);
    set(7, 12, color); set(7, 13, color); set(8, 13, color);
  };
  const eyebrowsAngry = () => {
    set(4, L, CF_COLORS.bodyDark);   set(4, L+1, CF_COLORS.angry);
    set(4, R, CF_COLORS.angry);      set(4, R+1, CF_COLORS.bodyDark);
  };
  const eyebrowsFurrow = () => {
    set(4, L, CF_COLORS.bodyDark);   set(4, L+1, CF_COLORS.bodyDark);
    set(4, R, CF_COLORS.bodyDark);   set(4, R+1, CF_COLORS.bodyDark);
  };

  switch (mood) {
    case 'thinking': {
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L+1, CF_COLORS.pupil); set(eR+1, R+1, CF_COLORS.pupil);
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      // thought-bubble dots
      set(2, 12, CF_COLORS.eye); set(1, 13, CF_COLORS.eye);
      set(0, 14, CF_COLORS.eye);
      break;
    }

    case 'smug': {
      // Half-lidded
      set(eR, L, CF_COLORS.bodyDark); set(eR, L+1, CF_COLORS.bodyDark);
      set(eR, R, CF_COLORS.bodyDark); set(eR, R+1, CF_COLORS.bodyDark);
      set(eR+1, L, CF_COLORS.eye); set(eR+1, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.eye); set(eR+1, R+1, CF_COLORS.pupil);
      // Grin
      set(7, 6, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      set(8, 6, CF_COLORS.bodyDark); set(8, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      blush();
      break;
    }

    case 'concede': {
      // X eyes
      fill2x2(eR, L, CF_COLORS.pupil);
      fill2x2(eR, R, CF_COLORS.pupil);
      // Small frown
      set(8, 6, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      break;
    }

    case 'happy': {
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L, CF_COLORS.pupil); set(eR+1, R, CF_COLORS.pupil);
      // U-smile
      set(8, 6, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      blush();
      break;
    }

    case 'surprised': {
      // Wide eyes, small pupils centered
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L, CF_COLORS.pupil); set(eR+1, R+1, CF_COLORS.pupil);
      // 'O' mouth
      set(7, 7, CF_COLORS.bodyDark); set(7, 8, CF_COLORS.bodyDark);
      set(8, 7, CF_COLORS.pupil); set(8, 8, CF_COLORS.pupil);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      break;
    }

    case 'angry': {
      // Squinted: only bottom-row whites + angled pupils
      set(eR+1, L, CF_COLORS.eye);    set(eR+1, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.pupil);  set(eR+1, R+1, CF_COLORS.eye);
      eyebrowsAngry();
      // Snarl mouth
      set(8, 6, CF_COLORS.bodyDark); set(8, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      // Anger spark above head
      set(0, 13, CF_COLORS.angry);
      set(1, 12, CF_COLORS.angry); set(1, 14, CF_COLORS.angry);
      set(2, 13, CF_COLORS.angry);
      break;
    }

    case 'annoyed': {
      // Right eye half-lid, left normal
      fill2x2(eR, L, CF_COLORS.eye); set(eR+1, L+1, CF_COLORS.pupil);
      set(eR, R, CF_COLORS.bodyDark); set(eR, R+1, CF_COLORS.bodyDark);
      set(eR+1, R, CF_COLORS.eye);    set(eR+1, R+1, CF_COLORS.pupil);
      // Flat short mouth
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      // "tch" mark
      set(2, 13, CF_COLORS.bodyDark); set(2, 14, CF_COLORS.bodyDark);
      break;
    }

    case 'scheming': {
      // Both eyes half-lidded, pupils glanced right
      set(eR, L, CF_COLORS.bodyDark); set(eR, L+1, CF_COLORS.bodyDark);
      set(eR, R, CF_COLORS.bodyDark); set(eR, R+1, CF_COLORS.bodyDark);
      set(eR+1, L, CF_COLORS.eye);    set(eR+1, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.eye);    set(eR+1, R+1, CF_COLORS.pupil);
      // Crooked grin (right corner up)
      set(8, 6, CF_COLORS.bodyDark); set(8, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      blush();
      break;
    }

    case 'shocked': {
      // Huge eyes — extend up to row 4
      set(4, L, CF_COLORS.eye); set(4, L+1, CF_COLORS.eye);
      set(4, R, CF_COLORS.eye); set(4, R+1, CF_COLORS.eye);
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L+1, CF_COLORS.pupil); set(eR+1, R, CF_COLORS.pupil);
      // Open mouth
      set(7, 6, CF_COLORS.bodyDark); set(7, 7, CF_COLORS.bodyDark);
      set(7, 8, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      set(8, 6, CF_COLORS.bodyDark); set(8, 7, CF_COLORS.pupil);
      set(8, 8, CF_COLORS.pupil); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      break;
    }

    case 'embarrassed': {
      // Eyes glance down — pupils on bottom row
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L, CF_COLORS.pupil); set(eR+1, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.pupil); set(eR+1, R+1, CF_COLORS.pupil);
      // Sheepish wavy mouth
      set(8, 6, CF_COLORS.bodyDark); set(7, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      wideBlush();
      break;
    }

    case 'sleepy': {
      // Closed eyes — bottom-row lines
      set(eR+1, L, CF_COLORS.bodyDark); set(eR+1, L+1, CF_COLORS.bodyDark);
      set(eR+1, R, CF_COLORS.bodyDark); set(eR+1, R+1, CF_COLORS.bodyDark);
      // Small flat mouth
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      // Z above head
      set(0, 12, CF_COLORS.eye); set(0, 13, CF_COLORS.eye); set(0, 14, CF_COLORS.eye);
      set(1, 13, CF_COLORS.eye);
      set(2, 12, CF_COLORS.eye); set(2, 13, CF_COLORS.eye); set(2, 14, CF_COLORS.eye);
      break;
    }

    case 'wink': {
      // Left eye normal
      fill2x2(eR, L, CF_COLORS.eye); set(eR+1, L, CF_COLORS.pupil);
      // Right eye closed
      set(eR+1, R, CF_COLORS.bodyDark); set(eR+1, R+1, CF_COLORS.bodyDark);
      // Smile
      set(8, 6, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      blush();
      break;
    }

    case 'focused': {
      // Pupils only — small dilated focus dots
      set(eR, L+1, CF_COLORS.pupil); set(eR+1, L+1, CF_COLORS.pupil);
      set(eR, R, CF_COLORS.pupil);   set(eR+1, R, CF_COLORS.pupil);
      eyebrowsFurrow();
      // Tight mouth
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      break;
    }

    case 'suspicious': {
      // Half-lid both eyes, pupils glance right
      set(eR, L, CF_COLORS.bodyDark); set(eR, L+1, CF_COLORS.bodyDark);
      set(eR, R, CF_COLORS.bodyDark); set(eR, R+1, CF_COLORS.bodyDark);
      set(eR+1, L, CF_COLORS.eye);    set(eR+1, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.eye);    set(eR+1, R+1, CF_COLORS.pupil);
      // Slight frown
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      set(9, 6, CF_COLORS.bodyDark); set(9, 9, CF_COLORS.bodyDark);
      break;
    }

    case 'determined': {
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L, CF_COLORS.pupil); set(eR+1, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.pupil); set(eR+1, R+1, CF_COLORS.pupil);
      eyebrowsFurrow();
      // Gritted teeth
      set(8, 6, CF_COLORS.bodyDark); set(8, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 6, CF_COLORS.bodyDark); set(9, 9, CF_COLORS.bodyDark);
      break;
    }

    case 'defeated': {
      // Droopy: lids on top, pupils sagging in opposite corners. Inner-bottom
      // pixels are explicitly white so the underlying body highlight doesn't
      // leak through and make the eyes look mismatched.
      set(eR, L, CF_COLORS.bodyDark); set(eR, L+1, CF_COLORS.bodyDark);
      set(eR, R, CF_COLORS.bodyDark); set(eR, R+1, CF_COLORS.bodyDark);
      set(eR+1, L, CF_COLORS.pupil);  set(eR+1, L+1, CF_COLORS.eye);
      set(eR+1, R, CF_COLORS.eye);    set(eR+1, R+1, CF_COLORS.pupil);
      // Downturned mouth
      set(7, 6, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      break;
    }

    case 'excited': {
      // Sparkle eyes: white whites + a yellow highlight pixel
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR, L, CF_COLORS.spark);
      set(eR, R+1, CF_COLORS.spark);
      set(eR+1, L+1, CF_COLORS.pupil); set(eR+1, R, CF_COLORS.pupil);
      // Wide smile
      set(7, 6, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      set(8, 6, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      // Sparkles around head
      set(2, 2, CF_COLORS.spark); set(3, 13, CF_COLORS.spark);
      set(1, 8, CF_COLORS.spark);
      blush();
      break;
    }

    case 'confused': {
      // Asymmetric pupils
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR, L+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.pupil);
      // Wavy mouth
      set(8, 6, CF_COLORS.bodyDark); set(7, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      // '?' floating above head
      set(0, 13, CF_COLORS.eye); set(0, 14, CF_COLORS.eye);
      set(1, 14, CF_COLORS.eye);
      set(2, 13, CF_COLORS.eye);
      set(4, 13, CF_COLORS.eye);
      break;
    }

    case 'nervous': {
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L+1, CF_COLORS.pupil); set(eR+1, R, CF_COLORS.pupil);
      // Wavy frown
      set(8, 6, CF_COLORS.bodyDark); set(9, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(9, 9, CF_COLORS.bodyDark);
      // Sweat drop
      set(1, 13, CF_COLORS.sweat);
      set(2, 12, CF_COLORS.sweat); set(2, 13, CF_COLORS.sweat);
      set(3, 13, CF_COLORS.sweat);
      break;
    }

    case 'smitten': {
      // Heart-shaped eyes
      set(eR, L, CF_COLORS.heart);   set(eR, L+1, CF_COLORS.heart);
      set(eR, R, CF_COLORS.heart);   set(eR, R+1, CF_COLORS.heart);
      set(eR+1, L, CF_COLORS.heart);
      set(eR+1, R+1, CF_COLORS.heart);
      // Big smile
      set(7, 6, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      set(8, 6, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      set(9, 7, CF_COLORS.bodyDark); set(9, 8, CF_COLORS.bodyDark);
      // Floating hearts
      set(2, 2, CF_COLORS.heart); set(2, 13, CF_COLORS.heart);
      blush();
      break;
    }

    case 'proud': {
      // Closed-arc eyes (^_^) — drawn in pupil so they pop against the body.
      set(eR, L, CF_COLORS.pupil);   set(eR, L+1, CF_COLORS.pupil);
      set(eR+1, L+1, CF_COLORS.pupil);
      set(eR, R, CF_COLORS.pupil);   set(eR, R+1, CF_COLORS.pupil);
      set(eR+1, R, CF_COLORS.pupil);
      // Big grin
      set(7, 6, CF_COLORS.bodyDark); set(7, 9, CF_COLORS.bodyDark);
      set(8, 6, CF_COLORS.bodyDark); set(8, 7, CF_COLORS.bodyDark);
      set(8, 8, CF_COLORS.bodyDark); set(8, 9, CF_COLORS.bodyDark);
      blush();
      break;
    }

    case 'idle':
    default: {
      fill2x2(eR, L, CF_COLORS.eye); fill2x2(eR, R, CF_COLORS.eye);
      set(eR+1, L, CF_COLORS.pupil); set(eR+1, R, CF_COLORS.pupil);
      set(8, 7, CF_COLORS.bodyDark); set(8, 8, CF_COLORS.bodyDark);
      break;
    }
  }
  return g;
}

const MOOD_ANIMATIONS = {
  thinking:    'cuttleBob 1.2s ease-in-out infinite',
  scheming:    'cuttleBob 2.4s ease-in-out infinite',
  focused:     'cuttleFloat 4s ease-in-out infinite',
  nervous:     'cuttleShake 0.6s ease-in-out infinite',
  shocked:     'cuttleShake 0.4s ease-in-out infinite',
  excited:     'cuttleBounce 0.8s ease-in-out infinite',
  smitten:     'cuttleBounce 1.6s ease-in-out infinite',
  sleepy:      'cuttleFloat 6s ease-in-out infinite',
  defeated:    'cuttleFloat 5s ease-in-out infinite',
};

function PixelCuttlefish({ mood = 'idle', size = 96 }) {
  const grid = makeCuttleGrid(mood);
  const px = size / 16;
  return (
    <div style={{
      width: size, height: size, position: 'relative',
      imageRendering: 'pixelated',
      animation: MOOD_ANIMATIONS[mood] || 'cuttleFloat 3s ease-in-out infinite',
    }}>
      {grid.map((row, y) => row.map((c, x) => c === '.' ? null : (
        <div key={`${y}-${x}`} style={{
          position: 'absolute',
          left: x * px, top: y * px,
          width: px, height: px,
          background: c,
        }} />
      )))}
    </div>
  );
}

// Stable pick: same (options, seed) → same choice, so the face doesn't flicker on rerender.
function seededPick(options, seed) {
  const h = ((seed | 0) * 2654435761) >>> 0;
  return options[h % options.length];
}

// Decide what face to show based on game state. Slight variation so the same
// situation doesn't always produce the same expression — picks are seeded by
// state.turn, so within a single turn the face is stable across rerenders.
function pickAIMood(state, opts = {}) {
  const { aiThinking = false } = opts;
  const turn = state?.turn ?? 0;

  // Game over.
  if (state?.winner === 'ai') {
    return seededPick(['happy', 'proud'], turn);
  }
  if (state?.winner === 'player') {
    return seededPick(['concede', 'defeated', 'concede'], turn);
  }

  // Computing a move.
  if (aiThinking) {
    return seededPick(['thinking', 'thinking', 'focused', 'scheming', 'suspicious'], turn);
  }

  // Reaction phase.
  if (state?.phase === 'WAITING_FOR_REACTION') {
    if (state.pending?.reactingPlayer === 1) {
      return seededPick(['thinking', 'scheming', 'suspicious', 'focused'], turn);
    }
    // Player must react — Cuttlebot just landed something.
    return seededPick(['smug', 'excited', 'scheming', 'proud'], turn);
  }

  // AI is being forced to discard from a Four.
  if (state?.phase === 'FOUR_DISCARD' && state.fourDiscarder === 1) {
    return seededPick(['annoyed', 'angry', 'defeated'], turn);
  }

  // React to the most recent log line.
  const last = state?.log?.[state.log.length - 1];
  if (last && last.msg) {
    const m = last.msg.toLowerCase();
    if (m.startsWith('you ')) {
      if (m.includes('jacked'))                  return seededPick(['angry', 'annoyed', 'shocked'], turn);
      if (m.includes('scuttled'))                return seededPick(['annoyed', 'defeated', 'nervous'], turn);
      if (m.includes('countered with'))          return seededPick(['annoyed', 'angry', 'suspicious'], turn);
      if (m.includes('put on glasses'))          return seededPick(['embarrassed', 'annoyed', 'wink'], turn);
      if (m.includes('played') && m.includes('for its effect'))
        return seededPick(['surprised', 'shocked', 'annoyed'], turn);
    } else if (m.startsWith('cuttlebot ')) {
      if (m.includes('scuttled'))                return seededPick(['smug', 'proud', 'excited'], turn);
      if (m.includes('jacked'))                  return seededPick(['smug', 'scheming', 'proud'], turn);
      if (m.includes('countered with'))          return seededPick(['smug', 'scheming', 'suspicious'], turn);
      if (m.includes('placed') && m.includes('king'))
        return seededPick(['proud', 'smug', 'excited'], turn);
      if (m.includes('put on glasses'))          return seededPick(['scheming', 'smug'], turn);
    }
  }

  // Player has Glasses out — Cuttlebot's hand is exposed.
  if (state?.playerField?.eights?.length > 0) {
    return seededPick(['embarrassed', 'annoyed', 'wink', 'idle', 'suspicious'], turn);
  }

  // Score-margin default.
  const aiScore = state?._aiScore ?? 0;
  const plScore = state?._playerScore ?? 0;
  const margin  = aiScore - plScore;
  if (margin >= 8)   return seededPick(['smug', 'proud', 'scheming', 'excited'], turn);
  if (margin >= 3)   return seededPick(['smug', 'scheming', 'idle'], turn);
  if (margin <= -8)  return seededPick(['nervous', 'defeated', 'annoyed'], turn);
  if (margin <= -3)  return seededPick(['nervous', 'suspicious', 'idle'], turn);

  return seededPick(['idle', 'smug', 'idle', 'suspicious'], turn);
}

// ─── Field-effect primitives (subtle, pixel-arty) ────────────────────────────
// Pixel-stepped expanding ring. Snaps through 4 frames so it reads as sprite-
// like rather than smooth. Used when a permanent (Queen / King / Glasses) is
// placed; color encodes the role.
function PixelRing({ color = '#ffd23f', size = 96, thickness = 2, duration = 280 }) {
  return (
    <div style={{
      position: 'absolute', left: '50%', top: '50%',
      width: size, height: size,
      marginLeft: -size / 2, marginTop: -size / 2,
      border: `${thickness}px solid ${color}`,
      boxSizing: 'border-box',
      pointerEvents: 'none',
      animation: `pixelRingExpand ${duration}ms steps(4, end) forwards`,
      opacity: 0,
    }} />
  );
}

// Pixel-stepped diagonal slash. Grows from 0 → length in 4 stepped frames then
// fades. Used at the impact moment of a Scuttle.
function PixelSlash({ length = 280, thickness = 4, color = '#ffd23f', angle = -54, duration = 220 }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!ref.current) return;
    ref.current.animate([
      { width: '0px', opacity: 1 },
      { width: `${length}px`, opacity: 1, offset: 0.6 },
      { width: `${length}px`, opacity: 0 },
    ], { duration, easing: 'steps(4, end)', fill: 'forwards' });
  }, [length, duration]);
  return (
    <div ref={ref} style={{
      position: 'absolute', left: '50%', top: '50%',
      transform: `translate(-50%, -50%) rotate(${angle}deg)`,
      transformOrigin: 'center',
      width: 0, height: thickness,
      background: color,
      pointerEvents: 'none',
    }} />
  );
}

// Pixel-stepped crack overlay — jagged segments drawn in. Used when an 8 is
// broken by a Ten.
function PixelCrack({ color = '#1e1b30', size = 76, duration = 260 }) {
  return (
    <svg width={size} height={size * 1.4} viewBox="0 0 76 106"
         style={{
           position: 'absolute', left: '50%', top: '50%',
           transform: 'translate(-50%, -50%)',
           pointerEvents: 'none',
           animation: `pixelCrackDraw ${duration}ms steps(4, end) forwards`,
           opacity: 0,
         }}>
      <path d="M14,18 L28,40 L20,52 L36,68 L26,84 L48,92" stroke={color}
            strokeWidth="3" fill="none" shapeRendering="crispEdges" />
      <path d="M44,12 L52,30 L60,40 L48,56 L62,72" stroke={color}
            strokeWidth="3" fill="none" shapeRendering="crispEdges" />
    </svg>
  );
}

// Pixel-stepped shatter: 4 small chunks fly diagonally and fade. Used when a
// royalty is wiped by a Six.
function PixelShatter({ color = '#a78bfa', duration = 320 }) {
  const chunks = [
    { dx: -16, dy: -14, rot: -25 },
    { dx:  18, dy: -12, rot:  20 },
    { dx: -14, dy:  16, rot: -15 },
    { dx:  16, dy:  18, rot:  30 },
  ];
  return (
    <div style={{
      position: 'absolute', left: '50%', top: '50%',
      width: 0, height: 0, pointerEvents: 'none',
    }}>
      {chunks.map((c, i) => <PixelShatterChunk key={i} {...c} color={color} duration={duration} />)}
    </div>
  );
}
function PixelShatterChunk({ dx, dy, rot, color, duration }) {
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!ref.current) return;
    ref.current.animate([
      { transform: 'translate(-50%, -50%) rotate(0deg)', opacity: 1 },
      { transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) rotate(${rot}deg)`, opacity: 0 },
    ], { duration, easing: 'steps(4, end)', fill: 'forwards' });
  }, []);
  return <div ref={ref} style={{
    position: 'absolute', left: 0, top: 0,
    width: 6, height: 6, background: color, opacity: 0,
  }} />;
}

// ─── Punchy callout ───────────────────────────────────────────────────────────
function Callout({ text, tone = 'attack', show, theme = 'lagoon' }) {
  if (theme === 'arcade') {
    const arcadeColor = tone === 'win' ? '#ffd23f' : tone === 'play' ? '#5fc8b6' : '#ff3b8b';
    const splitR = tone === 'win' ? '#ff3b8b' : tone === 'play' ? '#ffd23f' : '#ffd23f';
    const splitL = tone === 'win' ? '#5fc8b6' : tone === 'play' ? '#ff3b8b' : '#5fc8b6';
    return (
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        zIndex: 30,
      }}>
        <div style={{
          fontFamily: '"VT323", "Courier New", monospace',
          fontSize: 140, lineHeight: 0.9,
          letterSpacing: '0.08em', textTransform: 'uppercase',
          color: arcadeColor,
          textShadow: [
            `4px 0 0 ${splitR}`,
            `-4px 0 0 ${splitL}`,
            '6px 6px 0 #1a0e08',
            '0 0 14px currentColor',
            '0 0 28px currentColor',
          ].join(', '),
          opacity: show ? 1 : 0,
          animation: show ? 'arcadeCalloutIn 360ms steps(6,end) both, arcadeCalloutShake 480ms steps(8,end) 320ms both' : 'none',
          transition: 'opacity 120ms',
        }}>{text}</div>
      </div>
    );
  }
  const colors = {
    attack: theme === 'tideline' ? '#3d7a6a' : '#c2453d',
    play:   theme === 'tideline' ? '#5fa893' : '#e07a5f',
    win:    '#d4a23a',
  };
  return (
    <div style={{
      position: 'absolute', inset: 0, pointerEvents: 'none',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      zIndex: 30,
    }}>
      <div style={{
        fontFamily: '"Fraunces", serif', fontWeight: 700, fontStyle: 'italic',
        fontSize: 92, letterSpacing: '-0.02em',
        color: colors[tone],
        textShadow: '0 4px 0 rgba(255,255,255,0.4)',
        opacity: show ? 1 : 0,
        transform: show ? 'scale(1) rotate(-3deg)' : 'scale(0.6) rotate(-3deg)',
        transition: 'opacity 240ms, transform 240ms cubic-bezier(.2,1.7,.4,1)',
      }}>{text}</div>
    </div>
  );
}

// ─── Effect banner (medium-weight popup for one-off resolutions) ─────────────
// Sits between the tiny <Callout> and the full-screen Ace/Scuttle cinematics.
// Pops down from the top with: card sprite, headline, plain-English subline.
// Aimed at players who don't know the rules yet — the subline names the actor
// and the verb so it reads as a sentence at a glance.
//
// Content map: every supported flash.kind from engine-adapter resolutionFlash
// returns a {headline, sub, toneColor} based on the flash data + card labels.
function effectBannerContent(flash, cardOf) {
  if (!flash) return null;
  const card = cardOf(flash.sourceCard);
  const owner = flash.sourcePlayer === 0 ? 'You' : 'Cuttlebot';
  switch (flash.kind) {
    case 'two_scrap': {
      const target = cardOf(flash.targetCard);
      return { headline: 'ZAPPED!',       sub: `${owner} scrapped ${target.label}`,           sourceCard: flash.sourceCard, toneColor: '#ff3b8b' };
    }
    case 'three': {
      const recovered = cardOf(flash.recoveredCard);
      return { headline: 'RECOVERED!',    sub: `${owner} pulled ${recovered.label} from the scrap`, sourceCard: flash.sourceCard, toneColor: '#5fc8b6' };
    }
    case 'four': {
      const victim = flash.victimPlayer === 0 ? 'You' : 'Cuttlebot';
      const verb   = victim === 'You'    ? 'discard'  : 'discards';
      return { headline: 'FORCE DISCARD!', sub: `${victim} ${verb} 2 cards`,                    sourceCard: flash.sourceCard, toneColor: '#ffd23f' };
    }
    case 'five': {
      return { headline: 'DRAW TWO!',     sub: `${owner} drew 2 cards`,                         sourceCard: flash.sourceCard, toneColor: '#5fc8b6' };
    }
    case 'six': {
      return { headline: 'ROYAL WIPE!',   sub: 'Queens, Kings & Glasses scrapped',              sourceCard: flash.sourceCard, toneColor: '#ff3b8b' };
    }
    case 'seven': {
      return { headline: 'PEEK & PLAY!',  sub: `${owner} flipped the next card — must play it now`, sourceCard: flash.sourceCard, toneColor: '#ffd23f' };
    }
    case 'nine': {
      const returned = cardOf(flash.returnedCard);
      const dest     = flash.returnedTo === 0 ? "Your" : "Cuttlebot's";
      return { headline: 'RETURNED!',     sub: `${returned.label} sent back to ${dest} hand`,   sourceCard: flash.sourceCard, toneColor: '#5fc8b6' };
    }
  }
  return null;
}

function EffectBanner({ flash, cardOf, theme = 'lagoon', palette }) {
  const content = effectBannerContent(flash, cardOf);
  if (!content) return null;

  const { headline, sub, sourceCard, toneColor } = content;
  const isArcade = theme === 'arcade';

  const headlineColor = isArcade ? toneColor                  : (palette?.accent || '#c2453d');
  const splitR        = isArcade ? '#ffd23f'                  : 'transparent';
  const splitL        = isArcade ? '#5fc8b6'                  : 'transparent';
  const subColor      = isArcade ? '#f4e6c4'                  : (palette?.ink || '#2c1d12');
  const panelBg       = isArcade ? 'linear-gradient(180deg, #2a1810 0%, #1a0e08 100%)'
                                 : 'rgba(255,253,244,0.96)';
  const panelEdge     = isArcade ? '#d4a64a'                  : (palette?.tableEdge || '#dcb88f');

  return (
    <div data-testid="effect-banner"
         style={{
           position: 'fixed',
           top: 24, left: '50%', transform: 'translateX(-50%)',
           zIndex: 60, pointerEvents: 'none',
           display: 'flex', alignItems: 'center', gap: 16,
           padding: '12px 22px',
           minWidth: 360, maxWidth: 'min(680px, calc(100vw - 48px))',
           background: panelBg,
           border: `3px solid ${panelEdge}`,
           borderRadius: isArcade ? 4 : 12,
           boxShadow: isArcade
             ? `0 0 0 2px #1a0e08, 0 0 24px ${toneColor}88, 0 12px 30px rgba(0,0,0,0.55)`
             : `0 18px 32px -10px rgba(40,30,20,0.45), 0 0 0 1px rgba(0,0,0,0.06)`,
           animation: 'effectBannerIn 360ms cubic-bezier(.2,1.4,.4,1) both',
         }}>
      <div style={{ flex: '0 0 auto', transform: isArcade ? 'rotate(-4deg)' : 'rotate(-3deg)' }}>
        <PlayingCard card={cardOf(sourceCard)} w={48} theme={theme} compact />
      </div>
      <div style={{ flex: '1 1 auto', display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
        <div style={{
          fontFamily: isArcade ? '"VT323", "Courier New", monospace' : '"Fraunces", serif',
          fontWeight: isArcade ? 400 : 700, fontStyle: isArcade ? 'normal' : 'italic',
          fontSize: isArcade ? 44 : 30, lineHeight: 1,
          letterSpacing: isArcade ? '0.04em' : '-0.01em',
          textTransform: 'uppercase',
          color: headlineColor,
          textShadow: isArcade
            ? [`2px 0 0 ${splitR}`, `-2px 0 0 ${splitL}`, '3px 3px 0 #1a0e08', `0 0 12px ${toneColor}`].join(', ')
            : 'none',
        }}>{headline}</div>
        <div style={{
          fontFamily: isArcade ? '"JetBrains Mono", "Courier New", monospace' : '"Inter", sans-serif',
          fontSize: isArcade ? 16 : 14,
          color: subColor,
          opacity: isArcade ? 0.92 : 0.78,
          letterSpacing: isArcade ? '0.02em' : 0,
          whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
        }}>{sub}</div>
      </div>
      <style>{`
        @keyframes effectBannerIn {
          0%   { opacity: 0; transform: translateX(-50%) translateY(-32px) scale(0.92); }
          70%  { opacity: 1; transform: translateX(-50%) translateY(4px)   scale(1.02); }
          100% { opacity: 1; transform: translateX(-50%) translateY(0)     scale(1); }
        }
        @keyframes effectBannerOut {
          to { opacity: 0; transform: translateX(-50%) translateY(-12px) scale(0.96); }
        }
      `}</style>
    </div>
  );
}

// ─── Balatro-style tilt + sheen wrapper ──────────────────────────────────────
// RAF-driven, refs only (no React rerenders on mousemove). On hover the inner
// layer rotates toward the cursor and a radial-gradient sheen tracks it. On
// leave it springs back via a damped lerp. With `idle`, a constant low-amp
// breathing keeps the card feeling alive even at rest. Arcade theme quantizes
// tilt to 2° steps and skips the wiggle so pixel art doesn't smear.
function TiltCard({
  children,
  width,
  radius,
  theme = 'lagoon',
  idle = false,
  maxTilt = 10,
  hoverScale = 1.06,
  liftZ = 18,
  disabled = false,
  inspectable = false,  // right-click toggles a tilt-lock for studying the card
  pinned = false,       // Balatro-style: while pinned, card holds the engaged
                        // pose (scale/lift/sheen) even with the cursor off it
  shimmer = false,      // adds a slow conic-gradient holo layer (royalty cards)
  style,
}) {
  const outerRef = React.useRef(null);
  const tiltRef  = React.useRef(null);
  const sheenRef = React.useRef(null);
  // RAF closure reads the latest `pinned` via this ref so prop changes don't
  // require resubscribing the listeners.
  const pinnedRef = React.useRef(pinned);
  // Imperative handle exposed by the effect so the prop-sync effect below can
  // poke the RAF when pinned flips while the cursor is off-card.
  const apiRef = React.useRef(null);

  React.useEffect(() => {
    if (disabled) return;
    if (typeof window !== 'undefined' &&
        window.matchMedia &&
        window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      return;
    }
    const outer = outerRef.current;
    const tilt  = tiltRef.current;
    const sheen = sheenRef.current;
    if (!outer || !tilt) return;

    const isArcade = theme === 'arcade';
    const lerp = (a, b, t) => a + (b - a) * t;
    const q = (v, step) => Math.round(v / step) * step;

    let raf = 0;
    let hovered = false;
    let locked = false;
    let phase = Math.random() * Math.PI * 2;

    let curX = 0, curY = 0, curScale = 1, curZ = 0, curSheen = 0;
    let curMx = 50, curMy = 50;
    let tgtX = 0, tgtY = 0, tgtScale = 1, tgtZ = 0, tgtSheen = 0;
    let tgtMx = 50, tgtMy = 50;

    // Engaged = the cursor is over the card, the card is right-click-locked
    // for inspection, or the card is pinned (selected). All three keep the
    // tilt active and disable the settled short-circuit so the RAF loop keeps
    // writing CSS vars.
    const engaged = () => hovered || locked || pinnedRef.current;

    function tick() {
      const damp = engaged() ? 0.22 : 0.16;
      curX     = lerp(curX,     tgtX,     damp);
      curY     = lerp(curY,     tgtY,     damp);
      curScale = lerp(curScale, tgtScale, damp);
      curZ     = lerp(curZ,     tgtZ,     damp);
      curSheen = lerp(curSheen, tgtSheen, damp);
      curMx    = lerp(curMx,    tgtMx,    damp);
      curMy    = lerp(curMy,    tgtMy,    damp);

      let wx = 0, wy = 0, wr = 0;
      if (idle && !isArcade && !engaged()) {
        phase += 0.025;
        wx = Math.sin(phase) * 0.5;
        wy = Math.cos(phase) * 0.5;
        wr = Math.sin(phase * 2) * 0.6;
      }

      const rxRaw = -curY * maxTilt + wr;
      const ryRaw =  curX * maxTilt - wr;
      const rx = isArcade ? q(rxRaw, 2) : rxRaw;
      const ry = isArcade ? q(ryRaw, 2) : ryRaw;
      const tz = isArcade ? Math.round(curZ) : curZ;
      const tx = isArcade ? Math.round(wx) : wx;
      const ty = isArcade ? Math.round(wy) : wy;

      tilt.style.transform =
        `translate3d(${tx}px, ${ty}px, ${tz}px) ` +
        `rotateX(${rx.toFixed(3)}deg) rotateY(${ry.toFixed(3)}deg) ` +
        `scale(${curScale.toFixed(4)})`;

      // Expose the tilt state as CSS custom properties so deeper layers
      // (parallax detail, shimmer, etc.) can read them without prop drilling.
      // Hover ranges 0..1 — useful for compositing depth that fades in.
      const hoverAmt = curSheen / (isArcade ? 0.45 : 0.38);
      outer.style.setProperty('--rx', `${rx.toFixed(3)}deg`);
      outer.style.setProperty('--ry', `${ry.toFixed(3)}deg`);
      outer.style.setProperty('--mx', `${curMx.toFixed(2)}%`);
      outer.style.setProperty('--my', `${curMy.toFixed(2)}%`);
      outer.style.setProperty('--hover', hoverAmt.toFixed(3));

      if (sheen) {
        sheen.style.opacity = curSheen.toFixed(3);
        sheen.style.backgroundPosition = `${curMx.toFixed(2)}% ${curMy.toFixed(2)}%`;
      }

      const settled = !engaged() &&
        Math.abs(curX) < 0.001 && Math.abs(curY) < 0.001 &&
        Math.abs(curScale - 1) < 0.0005 && Math.abs(curZ) < 0.05 &&
        Math.abs(curSheen) < 0.005;
      if (settled && !(idle && !isArcade)) { raf = 0; return; }
      raf = requestAnimationFrame(tick);
    }
    function start() { if (!raf) raf = requestAnimationFrame(tick); }

    function onMove(e) {
      // Lock freezes the cursor input — current targets stay where they were.
      if (locked) return;
      const r = outer.getBoundingClientRect();
      const x = e.clientX - r.left;
      const y = e.clientY - r.top;
      tgtX = (x / r.width  - 0.5) * 2;
      tgtY = (y / r.height - 0.5) * 2;
      tgtMx = (x / r.width)  * 100;
      tgtMy = (y / r.height) * 100;
    }
    function applyEngagedTargets() {
      tgtScale = hoverScale;
      tgtZ = liftZ;
      tgtSheen = isArcade ? 0.45 : 0.38;
    }
    function onEnter() {
      hovered = true;
      applyEngagedTargets();
      start();
    }
    function onLeave() {
      hovered = false;
      if (locked) { start(); return; }   // keep frozen pose
      // Tilt and sheen-position always relax to neutral when the cursor leaves.
      tgtX = 0; tgtY = 0;
      tgtMx = 50; tgtMy = 50;
      if (pinnedRef.current) {
        // Pinned: hold scale/lift/sheen-amount engaged so the card keeps its
        // raised glamour while selected, even with the cursor elsewhere.
        applyEngagedTargets();
      } else {
        tgtScale = 1;
        tgtZ = 0;
        tgtSheen = 0;
      }
      start();
    }
    function onContextMenu(e) {
      if (!inspectable) return;
      e.preventDefault();
      locked = !locked;
      outer.dataset.tiltLocked = locked ? '1' : '0';
      if (locked) {
        // Capture the current pose as the lock target. cur* values are already
        // there; copy into tgt* so the lerp stays put.
        tgtX = curX; tgtY = curY;
        tgtMx = curMx; tgtMy = curMy;
        applyEngagedTargets();
      } else if (!hovered) {
        // Released and cursor is off-card: spring back as on a normal leave.
        onLeave();
      }
      start();
    }

    outer.addEventListener('mouseenter', onEnter);
    outer.addEventListener('mousemove',  onMove);
    outer.addEventListener('mouseleave', onLeave);
    if (inspectable) outer.addEventListener('contextmenu', onContextMenu);
    if (idle && !isArcade) start();

    // Expose hooks for the prop-sync effect: when `pinned` flips while the
    // cursor isn't on the card, we need to either pop into engaged pose or
    // spring back, both of which require kicking the RAF.
    apiRef.current = {
      onPin() {
        if (hovered) return;       // hover targets already drive the engaged pose
        applyEngagedTargets();
        start();
      },
      onUnpin() {
        if (hovered) return;       // still hovered: keep engaged via hover, no-op
        onLeave();                 // mirrors a normal mouseleave
      },
    };

    return () => {
      outer.removeEventListener('mouseenter', onEnter);
      outer.removeEventListener('mousemove',  onMove);
      outer.removeEventListener('mouseleave', onLeave);
      if (inspectable) outer.removeEventListener('contextmenu', onContextMenu);
      if (raf) cancelAnimationFrame(raf);
      apiRef.current = null;
    };
  }, [disabled, theme, idle, maxTilt, hoverScale, liftZ, inspectable]);

  // Sync the `pinned` prop into the ref the RAF reads, and nudge the loop
  // when the prop flips so a click made off-card still engages/releases.
  React.useEffect(() => {
    const prev = pinnedRef.current;
    pinnedRef.current = pinned;
    if (prev === pinned) return;
    const api = apiRef.current;
    if (!api) return;
    if (pinned) api.onPin();
    else        api.onUnpin();
  }, [pinned]);

  const sheenGradient = theme === 'arcade'
    ? 'radial-gradient(circle at 50% 50%, rgba(255, 59, 139, 0.55), rgba(0, 240, 200, 0.18) 35%, rgba(0,0,0,0) 60%)'
    : theme === 'tideline'
      ? 'radial-gradient(circle at 50% 50%, rgba(220, 240, 255, 0.85), rgba(220, 240, 255, 0) 55%)'
      : 'radial-gradient(circle at 50% 50%, rgba(255, 235, 200, 0.85), rgba(255, 235, 200, 0) 55%)';
  const sheenBlend = theme === 'arcade' ? 'screen' : 'overlay';
  const r = radius != null
    ? radius
    : (theme === 'arcade' ? 2 : Math.round((width || 84) * 0.10));

  // Galaxy-foil shimmer: layered conic gradients evoke the rainbow shift of a
  // holographic foil. Anchored at (--mx, --my) so the rainbow centre tracks
  // the cursor; multiplied by --hover so the effect fades in/out smoothly.
  // Use sparingly — saved for permanents (royalty / Glasses) on the field.
  const shimmerGradient =
    'conic-gradient(from calc(var(--ry, 0deg) * 8) at var(--mx, 50%) var(--my, 50%), ' +
    '#ff5b8a 0deg, #ffd23f 60deg, #5fc8b6 120deg, ' +
    '#7fc8ff 180deg, #a78bfa 240deg, #ff5b8a 300deg, #ff5b8a 360deg)';

  return (
    <div
      ref={outerRef}
      style={{
        position: 'relative',
        perspective: 900,
        transformStyle: 'preserve-3d',
        ...(style || {}),
      }}
    >
      <div
        ref={tiltRef}
        style={{
          position: 'relative',
          transformStyle: 'preserve-3d',
          willChange: 'transform',
          transform: 'translate3d(0,0,0) rotateX(0deg) rotateY(0deg) scale(1)',
        }}
      >
        {children}
        {shimmer && (
          <div
            data-tilt-layer="shimmer"
            aria-hidden="true"
            style={{
              position: 'absolute',
              inset: 0,
              pointerEvents: 'none',
              backgroundImage: shimmerGradient,
              backgroundSize: '220% 220%',
              backgroundPosition: 'var(--mx, 50%) var(--my, 50%)',
              mixBlendMode: theme === 'arcade' ? 'screen' : 'color-dodge',
              opacity: 'calc(0.18 + var(--hover, 0) * 0.32)',
              filter: 'blur(2px) saturate(1.4)',
              clipPath: `inset(0 round ${r}px)`,
              zIndex: 4,
            }}
          />
        )}
        <div
          ref={sheenRef}
          aria-hidden="true"
          style={{
            position: 'absolute',
            inset: 0,
            pointerEvents: 'none',
            opacity: 0,
            background: sheenGradient,
            backgroundSize: '180% 180%',
            backgroundRepeat: 'no-repeat',
            mixBlendMode: sheenBlend,
            clipPath: `inset(0 round ${r}px)`,
            zIndex: 5,
          }}
        />
      </div>
    </div>
  );
}

Object.assign(window, {
  PlayingCard, CardBack, PixelCuttlefish, Callout, EffectBanner, effectBannerContent,
  PixelRing, PixelSlash, PixelCrack, PixelShatter, TiltCard,
  SUIT_GLYPHS, RANK_LABELS, CUTTLE_MOODS, pickAIMood,
});
