import { create } from 'zustand'
import type { ClientPuzzle, CellStatus, Direction, WordSpan, InputMode, GameMode, StylusSettings, RecognitionResult } from './types'
import type { NarratorSettings } from './narrator'
import { defaultNarratorSettings } from './narrator'
import * as api from './api'
import type { ReceiptBody } from './api'
import * as offline from './offline'
import { SPEEDRUN_LIMITS } from './presets'

export interface PendingReceipt {
  receipt: ReceiptBody
  signature: string
  key_id: string
  receipt_url: string
  dismissed: boolean
}

export function getPendingReceipts(): PendingReceipt[] {
  try { return JSON.parse(localStorage.getItem('pending-receipts') || '[]') } catch { return [] }
}

function savePendingReceipt(r: PendingReceipt) {
  const all = getPendingReceipts().filter(x => x.receipt.receipt_id !== r.receipt.receipt_id)
  all.push(r)
  localStorage.setItem('pending-receipts', JSON.stringify(all))
}

function removePendingReceipt(receiptId: string) {
  const all = getPendingReceipts().filter(x => x.receipt.receipt_id !== receiptId)
  localStorage.setItem('pending-receipts', JSON.stringify(all))
}

interface PuzzleStore {
  puzzleId: string | null
  shortId: string | null
  dailyDate: string | null
  dailyPreset: string | null
  puzzle: ClientPuzzle | null
  loading: boolean
  error: string | null

  entries: string[][]
  cellStatus: CellStatus[][]
  selectedCell: { row: number; col: number } | null
  direction: Direction
  activeClue: number | null
  solved: boolean
  wordSpans: WordSpan[]

  startTime: number | null
  elapsed: number
  paused: boolean

  inputMode: InputMode
  stylusSettings: StylusSettings
  recognitionResult: RecognitionResult | null
  showDisambiguation: boolean

  gameMode: GameMode
  timeLimit: number
  marathonCount: number
  marathonFailed: boolean

  streak: { current: number; best: number } | null
  streakFreezes: number
  hintsUsed: number
  showOnboarding: boolean
  hasPasskey: boolean
  preferredPreset: string | null
  cellEverWrong: boolean[][]
  dailyChallenge: string | null
  challengeCompleted: boolean
  challengeDeleteCount: number
  challengePauseUsed: boolean
  challengeAutoCheckUsed: boolean
  challengePencilUsed: boolean
  challengeFirstCell: [number, number] | null
  challengeFirstLetterTime: number | null
  challengeCellFocusStart: number | null
  challengeLastLetterTimestamps: number[]
  challengeCellTimes: Record<string, number>
  challengeConsecutiveLetters: string[]
  challengeHadVowelStreak: boolean
  challengeHadConsonantStreak: boolean
  challengeHadTriple: boolean
  challengeFirstLetter: string | null
  challengeLastLetter: string | null
  challengeMaxGap: number
  challengeMaxCellTime: number
  challengeFirstRowFilledTime: number | null
  challengeCellsIn30s: number
  challengeEmptyCellsBefore: number
  completedChallenges: Record<string, boolean>
  showHeatmap: boolean
  solveRank: number | null
  solvePercentile: number | null
  solveTotalSolvers: number | null
  showEditor: boolean
  showAdmin: boolean

  pendingReceipt: PendingReceipt | null

  mpConnected: boolean
  mpRoomCode: string | null
  mpMode: string | null
  mpPlayers: { id: string; name: string; color: string; cursor: [number, number] | null; direction: string }[]
  mpWs: WebSocket | null
  mpStarted: boolean
  mpCurrentTurn: string | null
  mpMyId: string | null
  mpIsCreator: boolean

  partySubMode: string | null
  partyPhase: string | null
  partyRound: number
  partyTotalRounds: number
  partyClue: string | null
  partyDeadline: number | null
  partyAnswers: { id: string; text: string }[]
  partyResults: { id: string; text: string; author: string; authorColor: string; votes: number; isReal: boolean }[] | null
  partyRealAnswer: string | null
  partyScores: { id: string; name: string; color: string; score: number }[]
  partyMySubmission: string | null
  partyMyVote: string | null
  partyChoices: { id: string; label: string; text: string }[]
  partyMyPick: string | null
  partyCorrectPlayers: { id: string; name: string; color: string; rank: number; points: number }[]

  undoStack: { row: number; col: number; prev: string; next: string; pencil?: boolean }[]
  redoStack: { row: number; col: number; prev: string; next: string; pencil?: boolean }[]
  undo: () => void
  redo: () => void

  autoCheck: boolean
  toggleAutoCheck: () => void

  pencilMode: boolean
  pencilEntries: string[][]
  togglePencilMode: () => void

  narratorEnabled: boolean
  narratorSettings: NarratorSettings
  setNarratorEnabled: (enabled: boolean) => void
  setNarratorSettings: (settings: Partial<NarratorSettings>) => void

  partySubmitAnswer: (answer: string) => void
  partyVoteAnswer: (answerId: string) => void
  partySpeedAnswer: (answer: string) => void
  partyPickChoice: (choiceId: string) => void

  dismissReceipt: (receiptId: string) => void
  claimPendingReceipt: (receiptId: string) => Promise<void>
  checkPendingReceipts: () => void

  mpCreateRoom: (mode: string, partySub?: string) => Promise<void>
  mpJoinRoom: (code: string) => Promise<void>
  mpLeaveRoom: () => void
  mpStartGame: () => void
  mpPassTurn: () => void

  generatePuzzle: (difficulty?: string, size?: number, density?: number, clueDifficulty?: number, theme?: string, cryptic?: boolean, pack?: string) => Promise<void>
  loadDaily: (date: string, preset: string) => Promise<void>
  loadByShortId: (shortId: string) => Promise<void>
  fetchSession: () => Promise<void>
  setShowOnboarding: (show: boolean) => void
  selectCell: (row: number, col: number) => void
  toggleDirection: () => void
  typeLetter: (letter: string) => void
  deleteLetter: () => void
  moveSelection: (dr: number, dc: number) => void
  tabClue: (forward: boolean) => void
  deselectCell: () => void
  validateSolution: () => Promise<void>
  checkCell: () => Promise<void>
  revealLetter: () => Promise<void>
  revealWord: () => Promise<void>
  tick: () => void
  togglePause: () => void
  setInputMode: (mode: InputMode) => void
  setStylusSettings: (settings: Partial<StylusSettings>) => void
  setRecognitionResult: (result: RecognitionResult | null) => void
  acceptCandidate: (letter: string) => void
  dismissDisambiguation: () => void
  setGameMode: (mode: GameMode) => void
  setShowEditor: (show: boolean) => void
  setShowAdmin: (show: boolean) => void
  loadCommunityPuzzle: (id: string) => Promise<void>
  importPuzzle: (file: File) => Promise<void>
}

function buildWordSpans(puzzle: ClientPuzzle): WordSpan[] {
  const spans: WordSpan[] = []

  for (const clue of puzzle.clues_across) {
    const cells: [number, number][] = []
    // find the starting cell for this clue number
    for (const [key, num] of Object.entries(puzzle.cell_numbers)) {
      if (num === clue.number) {
        const [r, c] = key.split(',').map(Number)
        // walk right from this cell
        let col = c
        while (col < puzzle.width && puzzle.cells[r][col] !== 'black') {
          cells.push([r, col])
          col++
        }
        break
      }
    }
    if (cells.length > 0) {
      spans.push({ number: clue.number, direction: 'across', cells })
    }
  }

  for (const clue of puzzle.clues_down) {
    const cells: [number, number][] = []
    for (const [key, num] of Object.entries(puzzle.cell_numbers)) {
      if (num === clue.number) {
        const [r, c] = key.split(',').map(Number)
        let row = r
        while (row < puzzle.height && puzzle.cells[row][c] !== 'black') {
          cells.push([row, c])
          row++
        }
        break
      }
    }
    if (cells.length > 0) {
      spans.push({ number: clue.number, direction: 'down', cells })
    }
  }

  return spans
}

function findClueForCell(
  spans: WordSpan[],
  row: number,
  col: number,
  direction: Direction,
): WordSpan | undefined {
  return spans.find(
    (s) =>
      s.direction === direction &&
      s.cells.some(([r, c]) => r === row && c === col),
  )
}

function makeEmptyGrid(w: number, h: number): string[][] {
  return Array.from({ length: h }, () => Array(w).fill(''))
}

function makeStatusGrid(w: number, h: number): CellStatus[][] {
  return Array.from({ length: h }, () =>
    Array<CellStatus>(w).fill('default'),
  )
}

function makeBoolGrid(w: number, h: number): boolean[][] {
  return Array.from({ length: h }, () => Array(w).fill(false))
}

type PartyAnswerInfo = { id: string; text: string }
type PartyResultInfo = { id: string; text: string; author: string; authorColor: string; votes: number; isReal: boolean }
type PartyScoreInfo = { id: string; name: string; color: string; score: number }
type PartyCorrectInfo = { id: string; name: string; color: string; rank: number; points: number }
type PartyChoiceInfo = { id: string; label: string; text: string }

interface PartyDefaultsState {
  partySubMode: string | null
  partyPhase: string | null
  partyRound: number
  partyTotalRounds: number
  partyClue: string | null
  partyDeadline: number | null
  partyAnswers: PartyAnswerInfo[]
  partyResults: PartyResultInfo[] | null
  partyRealAnswer: string | null
  partyScores: PartyScoreInfo[]
  partyMySubmission: string | null
  partyMyVote: string | null
  partyChoices: PartyChoiceInfo[]
  partyMyPick: string | null
  partyCorrectPlayers: PartyCorrectInfo[]
}

function partyDefaults(): PartyDefaultsState {
  return {
    partySubMode: null,
    partyPhase: null,
    partyRound: 0,
    partyTotalRounds: 0,
    partyClue: null,
    partyDeadline: null,
    partyAnswers: [],
    partyResults: null,
    partyRealAnswer: null,
    partyScores: [],
    partyMySubmission: null,
    partyMyVote: null,
    partyChoices: [],
    partyMyPick: null,
    partyCorrectPlayers: [],
  }
}

interface ChallengeDefaultsState {
  challengeDeleteCount: number
  challengePauseUsed: boolean
  challengeAutoCheckUsed: boolean
  challengePencilUsed: boolean
  challengeFirstCell: [number, number] | null
  challengeFirstLetterTime: number | null
  challengeCellFocusStart: number | null
  challengeLastLetterTimestamps: number[]
  challengeCellTimes: Record<string, number>
  challengeConsecutiveLetters: string[]
  challengeHadVowelStreak: boolean
  challengeHadConsonantStreak: boolean
  challengeHadTriple: boolean
  challengeFirstLetter: string | null
  challengeLastLetter: string | null
  challengeMaxGap: number
  challengeMaxCellTime: number
  challengeFirstRowFilledTime: number | null
  challengeCellsIn30s: number
  challengeEmptyCellsBefore: number
}

function challengeDefaults(): ChallengeDefaultsState {
  return {
    challengeDeleteCount: 0,
    challengePauseUsed: false,
    challengeAutoCheckUsed: false,
    challengePencilUsed: false,
    challengeFirstCell: null,
    challengeFirstLetterTime: null,
    challengeCellFocusStart: null,
    challengeLastLetterTimestamps: [],
    challengeCellTimes: {},
    challengeConsecutiveLetters: [],
    challengeHadVowelStreak: false,
    challengeHadConsonantStreak: false,
    challengeHadTriple: false,
    challengeFirstLetter: null,
    challengeLastLetter: null,
    challengeMaxGap: 0,
    challengeMaxCellTime: 0,
    challengeFirstRowFilledTime: null,
    challengeCellsIn30s: 0,
    challengeEmptyCellsBefore: 0,
  }
}

const VOWELS = 'AEIOU'

function updateLetterStreaks(letters: string[], letter: string) {
  const buf = [...letters, letter].slice(-6)
  let hadVowel = false
  let hadConsonant = false
  let hadTriple = false
  if (buf.length >= 5) {
    const last5 = buf.slice(-5)
    if (last5.every(l => VOWELS.includes(l))) hadVowel = true
  }
  if (buf.length >= 6) {
    if (buf.every(l => !VOWELS.includes(l))) hadConsonant = true
  }
  if (buf.length >= 3) {
    const last3 = buf.slice(-3)
    if (last3[0] === last3[1] && last3[1] === last3[2]) hadTriple = true
  }
  return { buf, hadVowel, hadConsonant, hadTriple }
}

function isChallengeComplete(challenge: string, state: PuzzleStore): boolean {
  const hour = new Date().getHours()
  switch (challenge) {
    case 'purist': return state.hintsUsed === 0
    case 'speed_demon': {
      const limit = SPEEDRUN_LIMITS[state.dailyPreset || '']
      return limit ? state.elapsed < limit : false
    }
    case 'surgeon': return !state.cellEverWrong.some(r => r.some(v => v))
    case 'no_regrets': return state.challengeDeleteCount === 0
    case 'iron_will': return !state.challengePauseUsed
    case 'trust_fall': return !state.challengeAutoCheckUsed
    case 'inkwell': return !state.challengePencilUsed
    case 'night_owl': return hour >= 0 && hour < 5
    case 'early_bird': return hour >= 5 && hour < 8
    case 'rush_hour': return hour >= 12 && hour < 13
    case 'night_shift': return hour >= 20 && hour <= 23
    case 'afternoon_tea': return hour >= 15 && hour < 17
    case 'contrarian':
      if (!state.challengeFirstCell || !state.puzzle) return false
      return state.challengeFirstCell[0] >= Math.floor(state.puzzle.height / 2)
          && state.challengeFirstCell[1] >= Math.floor(state.puzzle.width / 2)
    case 'sunday_stroll': return state.elapsed >= 180
    case 'blitz': return state.elapsed < 60
    case 'opera_singer': return state.challengeHadVowelStreak
    case 'tongue_twister': return state.challengeHadConsonantStreak
    case 'mic_drop': return state.challengeEmptyCellsBefore === 1
    case 'jackrabbit': return state.challengeCellsIn30s >= 10
    case 'metronome': return state.challengeMaxGap <= 15
    case 'quickdraw': return state.challengeMaxCellTime <= 10
    case 'bookends':
      return state.challengeFirstLetter != null && state.challengeLastLetter != null
    case 'long_haul':
      return state.challengeFirstLetterTime != null && state.startTime != null
          && (state.challengeFirstLetterTime - state.startTime) >= 30000
    case 'fast_fingers':
      return state.challengeFirstRowFilledTime != null && state.startTime != null
          && (state.challengeFirstRowFilledTime - state.startTime) <= 20000
    case 'three_in_a_row': return state.challengeHadTriple
    case 'alphabetical': return 'ABCDE'.includes(state.challengeFirstLetter || '')
    case 'lucky_seven': return Math.abs(state.elapsed - 420) <= 10
    case 'even_steven': return state.elapsed % 2 === 0
    case 'minimalist': return state.hintsUsed > 0 && state.hintsUsed < 3
    case 'overtime': return state.elapsed >= 600
    default: return false
  }
}

function errMsg(e: Error | string): string {
  return e instanceof Error ? e.message : e
}

interface SolvedStateUpdates { solved: boolean; elapsed: number; hintsUsed: number; cellStatus?: CellStatus[][]; cellEverWrong?: boolean[][] }

interface MpPlayer { id: string; name: string; color: string; cursor: [number, number] | null; direction?: string; score?: number; connected?: boolean }
interface MpAnswer { id: string; text: string; author: string; author_color: string; votes: number; is_real: boolean }
interface MpOption { id: string; label: string; text: string }
interface MpScore { id: string; name: string; color: string; score: number }
interface MpMessage {
  type: string
  id: string
  name: string
  color: string
  row: number
  col: number
  direction: string
  letter: string
  grid?: string[][]
  players?: MpPlayer[]
  your_id?: string
  started?: boolean
  current_turn?: string | null
  phase: string
  round: number
  total: number
  clue: string
  deadline_secs: number
  answers: MpAnswer[]
  real_answer?: string | null
  scores: MpScore[]
  options: MpOption[]
  rank: number
  points: number
}

function handleMpMessage(msg: MpMessage, get: () => PuzzleStore, set: (partial: Partial<PuzzleStore> | ((s: PuzzleStore) => Partial<PuzzleStore>)) => void) {
  switch (msg.type) {
    case 'player_join':
      set(s => ({ mpPlayers: [...s.mpPlayers, { id: msg.id, name: msg.name, color: msg.color, cursor: null, direction: 'across' }] }))
      break
    case 'player_leave':
      set(s => ({ mpPlayers: s.mpPlayers.filter(p => p.id !== msg.id) }))
      break
    case 'player_cursor':
      set(s => ({ mpPlayers: s.mpPlayers.map(p => p.id === msg.id ? { ...p, cursor: [msg.row, msg.col], direction: msg.direction } : p) }))
      break
    case 'cell_update': {
      const entries = get().entries.map(r => [...r])
      entries[msg.row][msg.col] = msg.letter
      set({ entries })
      break
    }
    case 'cell_clear': {
      const entries = get().entries.map(r => [...r])
      entries[msg.row][msg.col] = ''
      set({ entries })
      break
    }
    case 'state':
      if (msg.grid) set({ entries: msg.grid })
      if (msg.players) {
        set({ mpPlayers: msg.players.map(p => ({ ...p, cursor: p.cursor ? [p.cursor[0], p.cursor[1]] : null, direction: p.direction || 'across' })) })
      }
      if (msg.your_id) set({ mpMyId: msg.your_id })
      set({ mpStarted: !!msg.started, mpCurrentTurn: msg.current_turn || null })
      break
    case 'turn_change':
      set({ mpCurrentTurn: msg.id })
      break
    case 'game_started':
      set({ mpStarted: true })
      break
    case 'party_round':
      set({
        partyPhase: msg.phase,
        partyRound: msg.round,
        partyTotalRounds: msg.total,
        partyClue: msg.clue,
        partyDeadline: Date.now() + msg.deadline_secs * 1000,
        partyMySubmission: null,
        partyMyVote: null,
        partyMyPick: null,
        partyAnswers: [],
        partyResults: null,
        partyRealAnswer: null,
        partyChoices: [],
        partyCorrectPlayers: [],
      })
      break
    case 'party_answers':
      set({ partyAnswers: msg.answers, partyPhase: 'vote' })
      break
    case 'party_results':
      set({
        partyResults: msg.answers.map(a => ({
          id: a.id, text: a.text, author: a.author,
          authorColor: a.author_color, votes: a.votes, isReal: a.is_real,
        })),
        partyRealAnswer: msg.real_answer || null,
        partyScores: msg.scores,
        partyPhase: 'results',
      })
      break
    case 'party_final':
      set({ partyScores: msg.scores, partyPhase: 'finished' })
      break
    case 'party_correct':
      set(s => ({
        partyCorrectPlayers: [...s.partyCorrectPlayers, {
          id: msg.id, name: msg.name, color: msg.color, rank: msg.rank, points: msg.points,
        }],
      }))
      break
    case 'party_choices':
      set({
        partyChoices: msg.options.map(o => ({ id: o.id, label: o.label, text: o.text })),
        partyPhase: 'choose',
      })
      break
  }
}

export const useStore = create<PuzzleStore>((set, get) => ({
  puzzleId: null,
  shortId: null,
  dailyDate: null,
  dailyPreset: null,
  puzzle: null,
  loading: false,
  error: null,
  entries: [],
  cellStatus: [],
  selectedCell: null,
  direction: 'across',
  activeClue: null,
  solved: false,
  wordSpans: [],
  startTime: null,
  elapsed: 0,
  paused: false,

  inputMode: 'auto',
  stylusSettings: {
    strokeWidth: 3,
    debounceMs: 800,
    autoFillThreshold: 0.5,
    ambiguityThreshold: 0.5,
    enableOnnx: true,
    enableTesseract: true,
    enableStrokeMatcher: true,
  },
  recognitionResult: null,
  showDisambiguation: false,

  gameMode: 'standard',
  timeLimit: 0,
  marathonCount: 0,
  marathonFailed: false,

  streak: null,
  streakFreezes: 0,
  hintsUsed: 0,
  showOnboarding: false,
  hasPasskey: false,
  preferredPreset: null,
  cellEverWrong: [],
  dailyChallenge: null,
  challengeCompleted: false,
  challengeDeleteCount: 0,
  challengePauseUsed: false,
  challengeAutoCheckUsed: false,
  challengePencilUsed: false,
  challengeFirstCell: null,
  challengeFirstLetterTime: null,
  challengeCellFocusStart: null,
  challengeLastLetterTimestamps: [],
  challengeCellTimes: {},
  challengeConsecutiveLetters: [],
  challengeHadVowelStreak: false,
  challengeHadConsonantStreak: false,
  challengeHadTriple: false,
  challengeFirstLetter: null,
  challengeLastLetter: null,
  challengeMaxGap: 0,
  challengeMaxCellTime: 0,
  challengeFirstRowFilledTime: null,
  challengeCellsIn30s: 0,
  challengeEmptyCellsBefore: 0,
  completedChallenges: {},
  showHeatmap: false,
  solveRank: null,
  solvePercentile: null,
  solveTotalSolvers: null,
  showEditor: false,
  showAdmin: false,

  pendingReceipt: null,

  mpConnected: false,
  mpRoomCode: null,
  mpMode: null,
  mpPlayers: [],
  mpWs: null,
  mpStarted: false,
  mpCurrentTurn: null,
  mpMyId: null,
  mpIsCreator: false,

  undoStack: [],
  redoStack: [],

  autoCheck: localStorage.getItem('cruciverb-autocheck') === 'true',

  pencilMode: false,
  pencilEntries: [],

  narratorEnabled: false,
  narratorSettings: { ...defaultNarratorSettings },

  setNarratorEnabled: (enabled: boolean) => {
    set({ narratorEnabled: enabled, narratorSettings: { ...get().narratorSettings, enabled } })
  },

  setNarratorSettings: (partial: Partial<NarratorSettings>) => {
    const next = { ...get().narratorSettings, ...partial }
    set({ narratorSettings: next, narratorEnabled: next.enabled })
  },

  undo: () => {
    const { undoStack, redoStack, entries, pencilEntries, mpWs } = get()
    if (undoStack.length === 0) return
    const action = undoStack[undoStack.length - 1]
    const newUndo = undoStack.slice(0, -1)
    const newRedo = [...redoStack, action]
    if (action.pencil) {
      const newPencil = pencilEntries.map(r => [...r])
      newPencil[action.row][action.col] = action.prev
      set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: newRedo, challengeDeleteCount: get().challengeDeleteCount + 1 })
    } else {
      const newEntries = entries.map(r => [...r])
      newEntries[action.row][action.col] = action.prev
      set({ entries: newEntries, undoStack: newUndo, redoStack: newRedo, challengeDeleteCount: get().challengeDeleteCount + 1 })
      if (mpWs && mpWs.readyState === WebSocket.OPEN) {
        if (action.prev) {
          mpWs.send(JSON.stringify({ type: 'cell', row: action.row, col: action.col, letter: action.prev }))
        } else {
          mpWs.send(JSON.stringify({ type: 'clear', row: action.row, col: action.col }))
        }
      }
    }
  },

  redo: () => {
    const { undoStack, redoStack, entries, pencilEntries, mpWs } = get()
    if (redoStack.length === 0) return
    const action = redoStack[redoStack.length - 1]
    const newRedo = redoStack.slice(0, -1)
    const newUndo = [...undoStack, action]
    if (action.pencil) {
      const newPencil = pencilEntries.map(r => [...r])
      newPencil[action.row][action.col] = action.next
      set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: newRedo })
    } else {
      const newEntries = entries.map(r => [...r])
      newEntries[action.row][action.col] = action.next
      set({ entries: newEntries, undoStack: newUndo, redoStack: newRedo })
      if (mpWs && mpWs.readyState === WebSocket.OPEN) {
        if (action.next) {
          mpWs.send(JSON.stringify({ type: 'cell', row: action.row, col: action.col, letter: action.next }))
        } else {
          mpWs.send(JSON.stringify({ type: 'clear', row: action.row, col: action.col }))
        }
      }
    }
  },

  toggleAutoCheck: () => {
    const next = !get().autoCheck
    localStorage.setItem('cruciverb-autocheck', String(next))
    set({ autoCheck: next })
  },

  togglePencilMode: () => {
    const next = !get().pencilMode
    const updates: Partial<PuzzleStore> = { pencilMode: next }
    if (next) updates.challengePencilUsed = true
    set(updates)
  },

  partySubMode: null,
  partyPhase: null,
  partyRound: 0,
  partyTotalRounds: 0,
  partyClue: null,
  partyDeadline: null,
  partyAnswers: [],
  partyResults: null,
  partyRealAnswer: null,
  partyScores: [],
  partyMySubmission: null,
  partyMyVote: null,
  partyChoices: [],
  partyMyPick: null,
  partyCorrectPlayers: [],

  partySubmitAnswer: (answer: string) => {
    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'party_submit', answer }))
    }
    set({ partyMySubmission: answer })
  },

  partyVoteAnswer: (answerId: string) => {
    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'party_vote', answer_id: answerId }))
    }
    set({ partyMyVote: answerId })
  },

  partySpeedAnswer: (answer: string) => {
    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'party_answer', answer }))
    }
  },

  partyPickChoice: (choiceId: string) => {
    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'party_pick', choice_id: choiceId }))
    }
    set({ partyMyPick: choiceId })
  },

  dismissReceipt: (receiptId: string) => {
    removePendingReceipt(receiptId)
    set({ pendingReceipt: null })
  },

  claimPendingReceipt: async (receiptId: string) => {
    const all = getPendingReceipts()
    const found = all.find(r => r.receipt.receipt_id === receiptId)
    if (!found) return
    try {
      await api.claimReceipt({ receipt: found.receipt, signature: found.signature, key_id: found.key_id })
      removePendingReceipt(receiptId)
      set({ pendingReceipt: null })
    } catch { /* ignored */ }
  },

  checkPendingReceipts: () => {
    const all = getPendingReceipts()
    const pending = all.find(r => !r.dismissed)
    if (pending) set({ pendingReceipt: pending })
  },

  mpCreateRoom: async (mode: string, partySub?: string) => {
    const { puzzleId, mpWs: oldWs } = get()
    if (mode !== 'party' && !puzzleId) throw new Error('No puzzle loaded')
    if (oldWs) oldWs.close()
    const room = await api.createRoom(puzzleId || '', mode, partySub)
    const code = room.code
    set({ mpRoomCode: code, mpMode: mode, mpIsCreator: true, partySubMode: partySub || (mode === 'party' ? 'wrong' : null) })
    const ws = api.connectRoom(code)
    ws.onopen = () => set({ mpConnected: true })
    ws.onclose = () => set({ mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, ...partyDefaults() })
    ws.onmessage = (e) => handleMpMessage(JSON.parse(e.data), get, set)
    set({ mpWs: ws })
  },

  mpJoinRoom: async (code: string) => {
    const { mpWs: oldWs } = get()
    if (oldWs) oldWs.close()
    const info = await api.getRoomInfo(code)
    set({ mpRoomCode: code, mpMode: info.mode, mpIsCreator: false, partySubMode: info.party?.sub_mode || null })
    if (info.puzzle_id && info.puzzle_id !== get().puzzleId) {
      const res = await api.fetchPuzzleById(info.puzzle_id).catch(() => null)
      if (res) {
        const spans = buildWordSpans(res.puzzle)
        set({
          puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle,
          entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
          pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
          cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height),
          selectedCell: null, direction: 'across', activeClue: null,
          wordSpans: spans, startTime: null, elapsed: 0, paused: false,
          solved: false, hintsUsed: 0, undoStack: [], redoStack: [],
        })
      }
    }
    const ws = api.connectRoom(code)
    ws.onopen = () => set({ mpConnected: true })
    ws.onclose = () => set({ mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, ...partyDefaults() })
    ws.onmessage = (e) => {
      // SAFETY: payloads are the multiplayer room server's own protocol frames, parsed at the boundary
      handleMpMessage(JSON.parse(e.data) as MpMessage, get, set)
    }
    set({ mpWs: ws })
  },

  mpLeaveRoom: () => {
    const { mpWs } = get()
    if (mpWs) mpWs.close()
    set({ mpConnected: false, mpRoomCode: null, mpMode: null, mpPlayers: [], mpWs: null, mpStarted: false, mpCurrentTurn: null, mpMyId: null, mpIsCreator: false, ...partyDefaults() })
  },

  mpStartGame: () => {
    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'start' }))
    }
  },

  mpPassTurn: () => {
    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'pass' }))
    }
  },

  fetchSession: async () => {
    try {
      const me = await api.fetchMe()
      set({
        streak: me.streak,
        streakFreezes: me.streak_freezes || 0,
        showOnboarding: me.is_new,
        hasPasskey: me.has_passkey,
        preferredPreset: me.preferred_preset || null,
      })
    } catch { /* ignored */ }
  },

  setShowOnboarding: (show: boolean) => set({ showOnboarding: show }),

  loadDaily: async (date, preset) => {
    if (get().loading) return
    set({ loading: true, error: null, solved: false })
    try {
      let res
      try {
        res = await api.fetchDaily(date, preset)
        // cache for offline
        offline.cachePuzzle(`daily-${date}-${preset}`, res).catch(() => {})
      } catch (_e) {
        // try offline cache
        const cached = await offline.getCachedPuzzle(`daily-${date}-${preset}`)
        if (cached) { res = cached } else { throw _e }
      }
      const spans = buildWordSpans(res.puzzle)
      const savedProgress = await offline.getProgress(res.id).catch(() => null)
      set({
        puzzleId: res.id,
        shortId: res.short_id,
        dailyDate: date,
        dailyPreset: preset,
        puzzle: res.puzzle,
        entries: savedProgress || makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height),
        cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height),
        selectedCell: null,
        direction: 'across',
        activeClue: null,
        wordSpans: spans,
        loading: false,
        startTime: null,
        elapsed: 0,
        paused: false,
        solved: false,
        hintsUsed: 0,
        undoStack: [], redoStack: [],
        solveRank: null, solvePercentile: null, solveTotalSolvers: null,
        dailyChallenge: null,
        challengeCompleted: false,
        showHeatmap: false,
        ...challengeDefaults(),
        challengeAutoCheckUsed: false,
      })
      // if already solved, show the completed state with original data
      if (res.already_solved) {
        const updates: SolvedStateUpdates = {
          solved: true,
          elapsed: res.solve_time || 0,
          hintsUsed: res.solve_hints || 0,
        }
        // restore per-cell results
        if (res.cell_results) {
          try {
            const cr = JSON.parse(res.cell_results)
            const newStatus: CellStatus[][] = cr.map((row: string[]) =>
              row.map((s: string): CellStatus => {
                if (s === 'r') return 'revealed'
                return 'default'
              })
            )
            const newEverWrong = cr.map((row: string[]) =>
              row.map((s: string) => s === 'w')
            )
            updates.cellStatus = newStatus
            updates.cellEverWrong = newEverWrong
          } catch { /* ignored */ }
        }
        set(updates)
      }
      api.fetchDailyChallenge(date, preset).then(ch => {
        set({ dailyChallenge: ch.challenge || null, challengeCompleted: ch.completed })
      }).catch(() => {})
    } catch (e) {
      set({ loading: false, error: offline.isOnline() ? errMsg(e instanceof Error ? e : String(e)) : 'No cached puzzles available offline' })
    }
  },

  loadByShortId: async (shortId) => {
    set({ loading: true, error: null, solved: false })
    try {
      const res = await api.fetchPuzzleByShortId(shortId)
      const spans = buildWordSpans(res.puzzle)
      set({
        puzzleId: res.id,
        shortId: res.short_id,
        puzzle: res.puzzle,
        entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height),
        cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height),
        selectedCell: null,
        direction: 'across',
        activeClue: null,
        wordSpans: spans,
        loading: false,
        startTime: null,
        elapsed: 0,
        paused: false,
        solved: false,
        hintsUsed: 0,
        undoStack: [], redoStack: [],
        solveRank: null, solvePercentile: null, solveTotalSolvers: null,
        dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false,
        ...challengeDefaults(), challengeAutoCheckUsed: false,
      })
    } catch (e) {
      set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) })
    }
  },

  setGameMode: (mode: GameMode) => set({ gameMode: mode }),
  setShowEditor: (show: boolean) => set({ showEditor: show }),
  setShowAdmin: (show: boolean) => set({ showAdmin: show }),

  loadCommunityPuzzle: async (id: string) => {
    if (get().loading) return
    set({ loading: true, error: null, solved: false })
    try {
      const res = await api.loadCommunityPuzzle(id)
      const spans = buildWordSpans(res.puzzle)
      set({
        puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle,
        entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height),
        cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height),
        selectedCell: null, direction: 'across', activeClue: null,
        wordSpans: spans, loading: false, startTime: null,
        elapsed: 0, paused: false, solved: false, hintsUsed: 0,
        undoStack: [], redoStack: [],
        solveRank: null, solvePercentile: null, solveTotalSolvers: null,
        dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false,
        ...challengeDefaults(), challengeAutoCheckUsed: false,
      })
    } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) }
  },

  importPuzzle: async (file: File) => {
    if (get().loading) return
    set({ loading: true, error: null, solved: false, solveRank: null, solvePercentile: null, solveTotalSolvers: null })
    try {
      const res = await api.importPuzzle(file)
      const spans = buildWordSpans(res.puzzle)
      set({
        puzzleId: res.id, shortId: res.short_id, puzzle: res.puzzle,
        entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height),
        cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height),
        selectedCell: null, direction: 'across', activeClue: null,
        wordSpans: spans, loading: false, startTime: null,
        elapsed: 0, paused: false, solved: false, hintsUsed: 0,
        undoStack: [], redoStack: [],
        dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false,
        ...challengeDefaults(), challengeAutoCheckUsed: false,
      })
    } catch (e) { set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) }) }
  },

  generatePuzzle: async (difficulty, size, density, clueDifficulty, theme, cryptic, pack) => {
    const { gameMode } = get()
    const timeLimit = gameMode === 'speedrun' ? (SPEEDRUN_LIMITS[difficulty || ''] || 300) : 0
    set({ loading: true, error: null, solved: false, timeLimit, marathonCount: 0, marathonFailed: false, solveRank: null, solvePercentile: null, solveTotalSolvers: null })
    try {
      const res = await api.generatePuzzle(difficulty, size, density, clueDifficulty, theme, cryptic, pack)
      const spans = buildWordSpans(res.puzzle)
      set({
        puzzleId: res.id,
        shortId: res.short_id,
        puzzle: res.puzzle,
        entries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        pencilEntries: makeEmptyGrid(res.puzzle.width, res.puzzle.height),
        cellStatus: makeStatusGrid(res.puzzle.width, res.puzzle.height),
        cellEverWrong: makeBoolGrid(res.puzzle.width, res.puzzle.height),
        selectedCell: null,
        direction: 'across',
        activeClue: null,
        wordSpans: spans,
        loading: false,
        startTime: null,
        elapsed: 0,
        paused: false,
        solved: false,
        hintsUsed: 0,
        undoStack: [], redoStack: [],
        dailyDate: null, dailyPreset: null, dailyChallenge: null, challengeCompleted: false, showHeatmap: false,
        ...challengeDefaults(), challengeAutoCheckUsed: false,
      })
    } catch (e) {
      set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) })
    }
  },

  selectCell: (row, col) => {
    const { puzzle, selectedCell, direction, wordSpans } = get()
    if (!puzzle || puzzle.cells[row][col] === 'black') return

    let newDir = direction
    if (selectedCell?.row === row && selectedCell?.col === col) {
      newDir = direction === 'across' ? 'down' : 'across'
    }

    const span = findClueForCell(wordSpans, row, col, newDir)
    const finalDir = span ? newDir : newDir === 'across' ? 'down' : 'across'
    const finalSpan = span || findClueForCell(wordSpans, row, col, finalDir)

    const updates: Partial<PuzzleStore> = {
      selectedCell: { row, col },
      direction: finalDir,
      activeClue: finalSpan?.number ?? null,
      startTime: get().startTime ?? Date.now(),
    }
    if (get().challengeFirstCell === null) {
      updates.challengeFirstCell = [row, col]
    }
    if (!selectedCell || selectedCell.row !== row || selectedCell.col !== col) {
      updates.challengeCellFocusStart = Date.now()
    }

    set(updates)

    const ws = get().mpWs
    if (ws && ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'cursor', row, col, direction: finalDir }))
    }
  },

  toggleDirection: () => {
    const { direction, selectedCell, wordSpans } = get()
    if (!selectedCell) return
    const newDir = direction === 'across' ? 'down' : 'across'
    const span = findClueForCell(
      wordSpans,
      selectedCell.row,
      selectedCell.col,
      newDir,
    )
    if (span) {
      set({ direction: newDir, activeClue: span.number })
    }
  },

  typeLetter: (letter) => {
    const { selectedCell, entries, cellStatus, puzzle, direction, solved, marathonFailed, mpStarted, mpCurrentTurn, mpMyId, pencilMode, pencilEntries, undoStack } =
      get()
    if (!selectedCell || !puzzle || solved || marathonFailed) return
    if (mpStarted && mpCurrentTurn !== mpMyId) return
    const { row, col } = selectedCell
    if (puzzle.cells[row][col] === 'black') return
    if (cellStatus[row][col] === 'revealed') {
      get().moveSelection(
        direction === 'down' ? 1 : 0,
        direction === 'across' ? 1 : 0,
      )
      return
    }

    const upper = letter.toUpperCase()
    const now = Date.now()

    // count empty cells before this letter (for mic_drop)
    let emptyCells = 0
    for (let r = 0; r < puzzle.height; r++) {
      for (let c = 0; c < puzzle.width; c++) {
        if (puzzle.cells[r][c] !== 'black' && !entries[r][c]) emptyCells++
      }
    }

    if (pencilMode) {
      const prev = pencilEntries[row]?.[col] || ''
      const newPencil = pencilEntries.map(r => [...r])
      newPencil[row][col] = upper
      const newUndo = [...undoStack, { row, col, prev, next: upper, pencil: true }].slice(-200)
      set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: [], startTime: get().startTime ?? now })
    } else {
      const prev = entries[row][col]
      const newEntries = entries.map((r) => [...r])
      newEntries[row][col] = upper
      const newPencil = pencilEntries.map(r => [...r])
      newPencil[row][col] = ''
      const newUndo = [...undoStack, { row, col, prev, next: upper }].slice(-200)
      set({ entries: newEntries, pencilEntries: newPencil, undoStack: newUndo, redoStack: [], startTime: get().startTime ?? now })

      const ws = get().mpWs
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'cell', row, col, letter: upper }))
      }

      if (get().autoCheck && get().puzzleId) {
        const sentLetter = upper
        api.requestHint(get().puzzleId!, 'check_cell', row, col, undefined, upper).then(res => {
          if (get().entries[row]?.[col] !== sentLetter) return
          const status = get().cellStatus.map(r => [...r])
          status[row][col] = res.is_correct ? 'correct' : 'incorrect'
          if (!res.is_correct) {
            const cew = get().cellEverWrong.map(r => [...r])
            cew[row][col] = true
            // auto-check flagged a wrong letter - this is what trust_fall tracks
            set({ cellStatus: status, cellEverWrong: cew, challengeAutoCheckUsed: true })
          } else {
            set({ cellStatus: status })
          }
        }).catch(() => {})
      }
    }

    // challenge tracking
    const chalUpdates: Partial<PuzzleStore> = { challengeLastLetter: upper, challengeEmptyCellsBefore: emptyCells }
    // quickdraw: dwell time on the current cell, up to now
    if (!pencilMode) {
      const focusStart = get().challengeCellFocusStart
      if (focusStart != null) {
        const dwell = Math.round((now - focusStart) / 1000)
        if (dwell > get().challengeMaxCellTime) chalUpdates.challengeMaxCellTime = dwell
      }
    }
    if (get().challengeFirstLetter === null) chalUpdates.challengeFirstLetter = upper
    if (get().challengeFirstLetterTime === null) chalUpdates.challengeFirstLetterTime = now

    // letter streak tracking
    const streaks = updateLetterStreaks(get().challengeConsecutiveLetters, upper)
    chalUpdates.challengeConsecutiveLetters = streaks.buf
    if (streaks.hadVowel) chalUpdates.challengeHadVowelStreak = true
    if (streaks.hadConsonant) chalUpdates.challengeHadConsonantStreak = true
    if (streaks.hadTriple) chalUpdates.challengeHadTriple = true

    // timing gaps
    const timestamps = get().challengeLastLetterTimestamps
    if (timestamps.length > 0) {
      const gap = (now - timestamps[timestamps.length - 1]) / 1000
      if (gap > get().challengeMaxGap) chalUpdates.challengeMaxGap = gap
    }
    chalUpdates.challengeLastLetterTimestamps = [...timestamps, now].slice(-10)

    // per-cell time tracking (simplified - record first entry time per cell)
    const cellKey = `${row},${col}`
    const cellTimes = get().challengeCellTimes
    if (!(cellKey in cellTimes)) {
      const enterTime = (now - (get().startTime || now)) / 1000
      chalUpdates.challengeCellTimes = { ...cellTimes, [cellKey]: enterTime }
    }

    // first row filled check
    if (get().challengeFirstRowFilledTime === null && !pencilMode) {
      const currentEntries = get().entries
      let firstRowFilled = true
      for (let c = 0; c < puzzle.width; c++) {
        if (puzzle.cells[0][c] !== 'black' && !currentEntries[0][c] && !(row === 0 && col === c)) {
          firstRowFilled = false
          break
        }
      }
      if (firstRowFilled) chalUpdates.challengeFirstRowFilledTime = now
    }

    // cells in first 30s
    const startT = get().startTime || now
    if (now - startT <= 30000 && !pencilMode) {
      chalUpdates.challengeCellsIn30s = get().challengeCellsIn30s + 1
    }

    set(chalUpdates)

    // advance to next empty cell in direction, skipping filled ones
    const dr = direction === 'down' ? 1 : 0
    const dc = direction === 'across' ? 1 : 0
    let nr = row + dr
    let nc = col + dc
    while (nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width) {
      if (puzzle.cells[nr][nc] === 'black') break
      if (!get().entries[nr][nc]) {
        const span = findClueForCell(get().wordSpans, nr, nc, direction)
        set({
          selectedCell: { row: nr, col: nc },
          activeClue: span?.number ?? get().activeClue,
        })
        return
      }
      nr += dr
      nc += dc
    }
    // if no empty cell found in this word, just move one step
    get().moveSelection(dr, dc)
  },

  deleteLetter: () => {
    const { selectedCell, entries, puzzle, direction, cellStatus, solved, marathonFailed, mpStarted, mpCurrentTurn, mpMyId, pencilMode, pencilEntries, undoStack } = get()
    if (mpStarted && mpCurrentTurn !== mpMyId) return
    if (!selectedCell || !puzzle || solved || marathonFailed) return
    const { row, col } = selectedCell

    if (pencilMode && pencilEntries[row]?.[col]) {
      const prev = pencilEntries[row][col]
      const newPencil = pencilEntries.map(r => [...r])
      newPencil[row][col] = ''
      const newUndo = [...undoStack, { row, col, prev, next: '', pencil: true }].slice(-200)
      set({ pencilEntries: newPencil, undoStack: newUndo, redoStack: [], challengeDeleteCount: get().challengeDeleteCount + 1 })
      return
    }

    if (entries[row][col] && cellStatus[row][col] !== 'revealed') {
      const prev = entries[row][col]
      const newEntries = entries.map((r) => [...r])
      newEntries[row][col] = ''
      const newUndo = [...undoStack, { row, col, prev, next: '' }].slice(-200)
      set({ entries: newEntries, undoStack: newUndo, redoStack: [], challengeDeleteCount: get().challengeDeleteCount + 1 })
      const ws = get().mpWs
      if (ws && ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify({ type: 'clear', row, col }))
      }
    } else {
      const dr = direction === 'down' ? -1 : 0
      const dc = direction === 'across' ? -1 : 0
      let nr = row + dr
      let nc = col + dc
      while (
        nr >= 0 &&
        nc >= 0 &&
        nr < puzzle.height &&
        nc < puzzle.width &&
        puzzle.cells[nr][nc] === 'black'
      ) {
        nr += dr
        nc += dc
      }
      if (nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width) {
        const newEntries = entries.map((r) => [...r])
        if (cellStatus[nr][nc] !== 'revealed') {
          const prev = newEntries[nr][nc]
          newEntries[nr][nc] = ''
          if (prev) {
            const newUndo = [...undoStack, { row: nr, col: nc, prev, next: '' }].slice(-200)
            set({ entries: newEntries, selectedCell: { row: nr, col: nc }, undoStack: newUndo, redoStack: [] })
          } else {
            set({ entries: newEntries, selectedCell: { row: nr, col: nc } })
          }
        } else {
          set({ selectedCell: { row: nr, col: nc } })
        }
        const ws = get().mpWs
        if (ws && ws.readyState === WebSocket.OPEN && cellStatus[nr][nc] !== 'revealed') {
          ws.send(JSON.stringify({ type: 'clear', row: nr, col: nc }))
        }
      }
    }
  },

  moveSelection: (dr, dc) => {
    const { selectedCell, puzzle, wordSpans } = get()
    if (!selectedCell || !puzzle) return
    let nr = selectedCell.row + dr
    let nc = selectedCell.col + dc

    // skip black cells
    while (
      nr >= 0 &&
      nc >= 0 &&
      nr < puzzle.height &&
      nc < puzzle.width &&
      puzzle.cells[nr][nc] === 'black'
    ) {
      nr += dr
      nc += dc
    }

    if (nr >= 0 && nc >= 0 && nr < puzzle.height && nc < puzzle.width) {
      const dir = get().direction
      const span = findClueForCell(wordSpans, nr, nc, dir)
      set({
        selectedCell: { row: nr, col: nc },
        activeClue: span?.number ?? get().activeClue,
      })
    }
  },

  tabClue: (forward) => {
    const { wordSpans, direction, activeClue, puzzle } = get()
    if (!puzzle) return
    const dirSpans = wordSpans.filter((s) => s.direction === direction)
    if (dirSpans.length === 0) return

    let idx = dirSpans.findIndex((s) => s.number === activeClue)
    if (forward) {
      idx = (idx + 1) % dirSpans.length
    } else {
      idx = (idx - 1 + dirSpans.length) % dirSpans.length
    }

    const span = dirSpans[idx]
    const [r, c] = span.cells[0]
    set({
      selectedCell: { row: r, col: c },
      activeClue: span.number,
    })
  },

  deselectCell: () => {
    set({ selectedCell: null, activeClue: null })
  },

  validateSolution: async () => {
    const { puzzleId, entries, elapsed, hintsUsed, mpRoomCode, solved } = get()
    if (!puzzleId || solved) return
    try {
      const cells = entries.map((row) =>
        row.map((c) => (c ? c : null)),
      )
      // build cell results for storage
      const cellResultsGrid = get().cellStatus.map((row, r) =>
        row.map((s, c) => {
          if (get().puzzle?.cells[r][c] === 'black') return 'b'
          if (s === 'revealed') return 'r'
          if (get().cellEverWrong[r]?.[c]) return 'w'
          return 'c'
        })
      )
      const cellResultsStr = JSON.stringify(cellResultsGrid)
      const res = await api.validateSolution(puzzleId, cells, elapsed, hintsUsed, mpRoomCode, cellResultsStr)
      const oldStatus = get().cellStatus
      const newStatus: CellStatus[][] = res.cells.map((row: string[], r: number) =>
        row.map((s: string, c: number): CellStatus => {
          if (oldStatus[r]?.[c] === 'revealed') return 'revealed'
          if (s === 'correct') return 'correct'
          if (s === 'incorrect') return 'incorrect'
          return 'default'
        }),
      )
      // track cells that were wrong on validation
      const cew = get().cellEverWrong.map(r => [...r])
      for (let r = 0; r < res.cells.length; r++) {
        for (let c = 0; c < res.cells[r].length; c++) {
          if (res.cells[r][c] === 'incorrect') cew[r][c] = true
        }
      }
      set({ cellStatus: newStatus, cellEverWrong: cew, solved: res.correct })
      if (res.correct && res.already_solved) {
        // already solved before - just show the solved state, no stats/challenges
        return
      }
      if (res.correct) {
        set({
          solveRank: res.rank || null,
          solvePercentile: res.percentile || null,
          solveTotalSolvers: res.total_solvers || null,
        })
        get().fetchSession()

        // check daily challenge completion
        const { dailyChallenge, dailyDate, dailyPreset, challengeCompleted } = get()
        if (dailyChallenge && dailyDate && dailyPreset && !challengeCompleted) {
          if (isChallengeComplete(dailyChallenge, get())) {
            set({ challengeCompleted: true })
            api.completeDailyChallenge(dailyDate, dailyPreset, dailyChallenge).catch(() => {})
          }
        }

        if (res.receipt) {
          const pending: PendingReceipt = {
            receipt: res.receipt.receipt,
            signature: res.receipt.signature,
            key_id: res.receipt.key_id,
            receipt_url: res.receipt.receipt_url ?? '',
            dismissed: false,
          }
          savePendingReceipt(pending)
          set({ pendingReceipt: pending })
        }

        // marathon mode: auto-load next puzzle after brief delay
        if (get().gameMode === 'marathon') {
          const count = get().marathonCount + 1
          set({ marathonCount: count })
          setTimeout(async () => {
            set({ loading: true, error: null, solved: false })
            try {
              const next = await api.generatePuzzle('glyph')
              const spans = buildWordSpans(next.puzzle)
              set({
                puzzleId: next.id,
                shortId: next.short_id,
                puzzle: next.puzzle,
                entries: makeEmptyGrid(next.puzzle.width, next.puzzle.height),
                pencilEntries: makeEmptyGrid(next.puzzle.width, next.puzzle.height),
                cellStatus: makeStatusGrid(next.puzzle.width, next.puzzle.height),
                selectedCell: null,
                direction: 'across',
                activeClue: null,
                wordSpans: spans,
                loading: false,
                startTime: Date.now(),
                elapsed: 0,
                paused: false,
                hintsUsed: 0,
                undoStack: [], redoStack: [],
              })
            } catch (e) {
              set({ loading: false, error: errMsg(e instanceof Error ? e : String(e)) })
            }
          }, 800)
        }
      }
    } catch { /* ignored */ }
  },

  checkCell: async () => {
    const { puzzleId, selectedCell, entries, solved } = get()
    if (!puzzleId || !selectedCell || solved) return
    const { row, col } = selectedCell
    const letter = entries[row][col]
    if (!letter) return
    set({ hintsUsed: get().hintsUsed + 1 })
    try {
      const res = await api.requestHint(puzzleId, 'check_cell', row, col, undefined, letter)
      if (get().entries[row]?.[col] !== letter) return
      const newStatus = get().cellStatus.map((r) => [...r])
      newStatus[row][col] = res.is_correct ? 'correct' : 'incorrect'
      if (!res.is_correct) {
        const cew = get().cellEverWrong.map(r => [...r])
        cew[row][col] = true
        set({ cellStatus: newStatus, cellEverWrong: cew })
      } else {
        set({ cellStatus: newStatus })
      }
    } catch (_e) {
      set({ hintsUsed: get().hintsUsed - 1 })
    }
  },

  revealLetter: async () => {
    const { puzzleId, selectedCell, solved } = get()
    if (!puzzleId || !selectedCell || solved) return
    const { row, col } = selectedCell
    set({ hintsUsed: get().hintsUsed + 1 })
    try {
      const res = await api.requestHint(puzzleId, 'reveal_letter', row, col)
      const newEntries = get().entries.map((r) => [...r])
      const newStatus = get().cellStatus.map((r) => [...r])
      newEntries[row][col] = res.letter
      newStatus[row][col] = 'revealed'
      set({ entries: newEntries, cellStatus: newStatus })
    } catch (_e) {
      set({ hintsUsed: get().hintsUsed - 1 })
    }
  },

  revealWord: async () => {
    const { puzzleId, selectedCell, direction, solved } = get()
    if (!puzzleId || !selectedCell || solved) return
    set({ hintsUsed: get().hintsUsed + 1 })
    try {
      const res = await api.requestHint(puzzleId, 'reveal_word', selectedCell.row, selectedCell.col, direction)
      const newEntries = get().entries.map((r) => [...r])
      const newStatus = get().cellStatus.map((r) => [...r])
      for (const { row, col, letter } of res.letters) {
        newEntries[row][col] = letter
        newStatus[row][col] = 'revealed'
      }
      set({ entries: newEntries, cellStatus: newStatus })
    } catch (_e) {
      set({ hintsUsed: get().hintsUsed - 1 })
    }
  },

  tick: () => {
    const { startTime, paused, solved, gameMode, timeLimit, marathonFailed } = get()
    if (!startTime || paused || solved || marathonFailed) return
    const elapsed = Math.floor((Date.now() - startTime) / 1000)
    set({ elapsed })

    // speed run: auto-fail when time runs out
    if (gameMode === 'speedrun' && timeLimit > 0 && elapsed >= timeLimit) {
      set({ solved: true })
    }
    // marathon: fail if 2 minutes per puzzle exceeded
    if (gameMode === 'marathon' && elapsed >= 120) {
      set({ marathonFailed: true })
    }
  },

  togglePause: () => {
    const { gameMode } = get()
    if (gameMode === 'speedrun') return
    set((s) => ({ paused: !s.paused, challengePauseUsed: true }))
  },

  setInputMode: (mode) => set({ inputMode: mode }),

  setStylusSettings: (partial) =>
    set((s) => ({ stylusSettings: { ...s.stylusSettings, ...partial } })),

  setRecognitionResult: (result) => {
    if (!result) {
      set({ recognitionResult: null, showDisambiguation: false })
      return
    }
    set({ recognitionResult: result })
  },

  acceptCandidate: (letter) => {
    set({ recognitionResult: null, showDisambiguation: false })
    get().typeLetter(letter)
  },

  dismissDisambiguation: () =>
    set({ showDisambiguation: false, recognitionResult: null }),
}))
