import { memo, useEffect, useState } from 'react';

import { cn } from '@/lib/utils';

import { MAX_GUESSES, WORD_LENGTH, type BoardState, type Pattern } from './engine';

/**
 * The board. One component for all three jobs — you playing, the replay, and
 * the gallery thumbnail — because three near-identical boards is how they drift.
 *
 * It draws no outer chrome: the shell's card supplies the border, ground and
 * padding. Every colour is a `tile-*` token, which is what lets the
 * high-contrast palette swap the fills without this file knowing.
 */

type TileState = 'G' | 'Y' | 'X';

/**
 * Fill, border and glyph ink per state. The fills are the same in both themes
 * (the board is the one theme-invariant object on the site) and the `-fg`
 * tokens are measured against their own fill: exact 13.08, present 11.16,
 * absent 7.75 dark / 5.74 light; in high contrast white on each fill clears
 * 4.9 or better. The numbers live beside the tokens in index.css.
 */
const TILE_CLASS: Record<TileState, string> = {
  G: 'bg-tile-exact text-tile-exact-fg border-tile-exact',
  Y: 'bg-tile-present text-tile-present-fg border-tile-present',
  X: 'bg-tile-absent text-tile-absent-fg border-tile-absent',
};

/**
 * A glyph per state, shown only in high-contrast mode.
 *
 * Green/amber/grey is a colour-only distinction, which is exactly why the
 * original game ships a high-contrast mode. A second channel means the result
 * survives deuteranopia, a projector with the colour balance wrong, and a
 * screenshot printed in black and white. Absent has no glyph: a filled grey
 * tile with nothing in the corner is the third state.
 */
const TILE_GLYPH: Record<TileState, string> = { G: '●', Y: '◆', X: '' };

/** Flip length and per-tile stagger, matching the keyframes in index.css. */
const FLIP_MS = 520;
const STAGGER_MS = 100;

/**
 * Whether a revealing tile has reached the half-way point of its flip.
 *
 * The real flip lands the colour at 90°, when the face is edge-on: before that
 * the tile still looks typed, after it the tile is scored. Applying the colour
 * on mount and rotating a coloured tile reads as a wobble, not a reveal. Under
 * reduced motion the colour lands immediately — there is no flip to hide it
 * behind, and the row's announcement carries the result regardless.
 */
function useLanded(revealing: boolean, index: number): boolean {
  const [landed, setLanded] = useState(!revealing);
  useEffect(() => {
    if (!revealing) return;
    if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setLanded(true);
      return;
    }
    const id = window.setTimeout(() => setLanded(true), index * STAGGER_MS + FLIP_MS / 2);
    return () => window.clearTimeout(id);
  }, [revealing, index]);
  return landed;
}

function Tile({
  letter,
  tile,
  index,
  revealing,
  compact,
}: {
  letter: string;
  tile: TileState | null;
  index: number;
  revealing: boolean;
  compact?: boolean;
}) {
  const filled = letter !== '';
  const landed = useLanded(revealing && tile !== null, index);
  const scored = tile !== null && landed;
  return (
    <div
      className={cn(
        'relative grid select-none place-items-center font-sans font-semibold uppercase',
        'aspect-square rounded-tile transition-colors duration-1',
        compact ? 'border text-[0.55rem]' : 'border-2 text-[1.375rem] sm:text-2xl',
        scored
          ? TILE_CLASS[tile]
          : filled
            ? // Typed, unscored: a strong edge says "this is yours", the letter is
              // plain ink. No fill, so the scored fill is an unmistakable change.
              'border-border-strong bg-transparent text-fg'
            : 'border-tile-empty bg-transparent text-fg',
        // The flip is a rotation about X, staggered along the row; the colour
        // lands at the half-way point (see useLanded). `prefers-reduced-motion`
        // clamps it to nothing globally, which is why announceRow() exists.
        revealing && tile && 'motion-safe:animate-[tile-flip_520ms_ease-enter_both]',
        filled && !tile && 'motion-safe:animate-[tile-pop_120ms_ease-enter]',
      )}
      style={revealing && tile ? { animationDelay: `${index * STAGGER_MS}ms` } : undefined}
      aria-hidden="true"
    >
      {letter}
      {scored ? (
        <span
          className={cn(
            'pointer-events-none absolute bottom-0 right-0.5 leading-none opacity-0',
            "[:root[data-contrast='high']_&]:opacity-100",
            compact ? 'text-[0.6em]' : 'text-[0.5em]',
          )}
        >
          {TILE_GLYPH[tile]}
        </span>
      ) : null}
    </div>
  );
}

function Row({
  guess,
  pattern,
  revealing,
  shake,
  compact,
}: {
  guess: string;
  pattern: Pattern | null;
  revealing: boolean;
  shake?: boolean;
  compact?: boolean;
}) {
  const letters = guess.padEnd(WORD_LENGTH, ' ').slice(0, WORD_LENGTH);
  // The shake is armed by the prop and disarmed by its own end event, so a
  // second refusal shakes the row again instead of leaving the class in place.
  const [shaking, setShaking] = useState(false);
  useEffect(() => {
    if (shake) setShaking(true);
  }, [shake]);
  return (
    <div
      className={cn('grid gap-1 sm:gap-1.5', shaking && 'motion-safe:animate-[tile-shake_400ms_ease-enter]')}
      style={{ gridTemplateColumns: `repeat(${WORD_LENGTH}, minmax(0, 1fr))` }}
      onAnimationEnd={(event) => {
        if (event.animationName === 'tile-shake') setShaking(false);
      }}
    >
      {[...letters].map((letter, i) => (
        <Tile
          key={i}
          letter={letter.trim()}
          tile={pattern ? (pattern[i] as TileState) : null}
          index={i}
          revealing={revealing}
          compact={compact}
        />
      ))}
    </div>
  );
}

function boardLabel(state: BoardState): string {
  const played = state.rows.length;
  if (state.status === 'won') return `Solved in ${played} of ${MAX_GUESSES} guesses.`;
  if (state.status === 'lost') return `All ${MAX_GUESSES} guesses played, not solved.`;
  if (played === 0) return `Empty board, ${MAX_GUESSES} guesses remaining.`;
  return `${played} of ${MAX_GUESSES} guesses played.`;
}

export const Board = memo(function Board({
  state,
  compact,
}: {
  state: BoardState;
  compact?: boolean;
}) {
  const rows = state.rows;
  const draftRow = rows.length < MAX_GUESSES && state.status === 'playing' ? state.draft : null;
  const blanks = MAX_GUESSES - rows.length - (draftRow === null ? 0 : 1);

  return (
    <div
      className={cn(
        'grid',
        // An explicit width, not `w-full max-w-sm`. An empty board has no
        // intrinsic width, so inside a shrink-to-fit parent `w-full` resolves
        // to nothing and the board collapses to a stub — which is exactly the
        // state the interactive board starts in.
        compact ? 'w-[7rem] gap-0.5' : 'w-[min(100%,20rem)] min-w-[15rem] gap-1 sm:gap-1.5',
      )}
      role="img"
      aria-label={boardLabel(state)}
    >
      {rows.map((row, i) => (
        <Row
          key={`${row.guess}-${i}`}
          guess={row.guess}
          pattern={row.pattern}
          // The thumbnail is the shape of the run, not a replay: no flip there.
          revealing={!compact && i === rows.length - 1}
          compact={compact}
        />
      ))}
      {draftRow !== null ? (
        <Row
          guess={draftRow}
          pattern={null}
          revealing={false}
          shake={state.invalid !== null}
          compact={compact}
        />
      ) : null}
      {Array.from({ length: Math.max(0, blanks) }, (_, i) => (
        <Row key={`blank-${i}`} guess="" pattern={null} revealing={false} compact={compact} />
      ))}
    </div>
  );
});

export default Board;
