import { animate, motion, useMotionTemplate, useMotionValue } from 'framer-motion'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'

import PrefetchLink from '@/components/PrefetchLink'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { expectedAnswerForQuestion,
	 fetchGekanatorExtraQuestions,
	 fetchGekanatorQuestions,
	 fetchGekanatorPosts,
	 isLearnedSemanticQuestion,
	 learnedSemanticSideForPost,
	 normalizeTitleLengthCondition,
	 questionIdForCondition,
	 restoreGekanatorQuestion,
	 saveGekanatorExtraQuestionAnswers,
	 saveGekanatorGame,
	 saveGekanatorQuestionSuggestion,
	 storeGekanatorQuestion,
	 titleLengthMinimumForCondition } from '@/lib/gekanator'
import { recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
	 monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import {
  getClientGekanatorBackgroundMotion,
  setClientGekanatorBackgroundMotion,
} from '@/lib/settings'
import { cn } from '@/lib/utils'

import type { FC } from 'react'
import type { Transition } from 'framer-motion'

import type { GekanatorAnswerLog,
	      GekanatorAnswerValue,
	      GekanatorExtraQuestion,
	      GekanatorQuestionPurpose,
	      GekanatorQuestionCondition,
	      GekanatorQuestionKind,
	      GekanatorQuestion,
	      StoredGekanatorQuestion } from '@/lib/gekanator'
import type {
    RecoveredCandidatePost,
    RecoveredCandidateState,
} from '@/lib/gekanatorCandidateRecovery'
import type { Post, User } from '@/types'

type Phase =
    | 'intro'
    | 'question'
    | 'guess'
    | 'continue'
    | 'end'
    | 'review'
    | 'question_suggestion'
    | 'extra_questions'
    | 'learned'

type AnswerOption = {
    label:  string
    value:  GekanatorAnswerValue }

type Confidence = {
    post:     Post
    score:    number
    percent:  number }

type AnswerPreview = {
    answer:              GekanatorAnswerValue
    top:                 Confidence | null
    candidateCount:      number
    effectiveCandidates: number
    entropy:             number }

type GameSnapshot = {
    phase:                   Phase
    scores:                  Map<number, number>
    answers:                 GekanatorAnswerLog[]
    askedIds:                Set<string>
    softenedQuestionIds:     Set<string>
    recoveredCandidatePosts: Map<number, RecoveredCandidateState>
    recoveryStepCount:       number
    askedQuestionBank:       GekanatorQuestion[]
    search:                  string
    selectingCorrectPost:    boolean
    rejectedPostIds:         Set<number>
    lastGuessQuestionCount:  number
    lastRejectedGuessId:     number | null
    winningRunTargetId:      number | null
    winningRunStartAnswerCount: number | null
    guessReason:             GuessReason | null
    activeGuessId:           number | null
    reviewGuessedPostId:     number | null
    reviewCorrectPostId:     number | null }

type StoredGekanatorGame = {
    phase:                       Phase
    scores:                      [number, number][]
    answers:                     GekanatorAnswerLog[]
    askedIds:                    string[]
    softenedQuestionIds:         string[]
    recoveredCandidatePosts?:    RecoveredCandidatePost[]
    recoveryStepCount?:          number
    askedQuestionBank?:          StoredGekanatorQuestion[]
    askedQuestionBankIds?:       string[]
    search:                      string
    selectingCorrectPost:        boolean
    saved:                       boolean
    resultWon:                   boolean | null
    rejectedPostIds:             number[]
    lastGuessQuestionCount:      number
    lastRejectedGuessId:         number | null
    winningRunTargetId?:         number | null
    winningRunStartAnswerCount?: number | null
    guessReason?:                GuessReason | null
    activeGuessId:               number | null
    reviewGuessedPostId:         number | null
    reviewCorrectPostId:         number | null
    savedGameId:                 number | null
    learnedExampleCount?:        number | null
    gameSeed?:                   string
    questionSuggestionEntryMode?: 'search' | 'new'
    questionSuggestionSearch?:   string
    questionSuggestionSelectedId?: number | null
    questionSuggestion:          string
    questionSuggestionAnswer:    GekanatorAnswerValue
    questionSuggestionCount?:    number
    extraQuestions?:             GekanatorExtraQuestion[]
    extraQuestionAnswers?:       Record<string, GekanatorAnswerValue>
    extraQuestionState?:         'idle' | 'loading' | 'ready' | 'empty' | 'saved' }

type RecentGameSummary = {
    correctPostId:   number
    firstQuestionId: string | null
    savedAt:         number }

type BackgroundMotionMode = 'on' | 'calm' | 'off'

type GuessReason =
    | 'hard_max_questions'
    | 'winning_run_finished'
    | 'question_count_checkpoint'
    | 'question_generation_stalled'

type QuestionMode =
    | 'winning_run'
    | 'normal'
    | null

type QuestionSelection = {
    question:           GekanatorQuestion
    questionPurpose:    GekanatorQuestionPurpose
    effectiveQuestion:  boolean
    learningQuestion:   boolean }

type QuestionBuildMode =
    | 'split'
    | 'confirmation'

type QuestionSuggestionEntryMode =
    | 'search'
    | 'new'

type MascotState =
    | 'idle'
    | 'thinking_far'
    | 'thinking_mid'
    | 'thinking_near'
    | 'confident'
    | 'celebrate'
    | 'failed'

const answerOptions: AnswerOption[] = [
    { label: 'はい', value: 'yes' },
    { label: 'いいえ', value: 'no' },
    { label: '部分的にそう', value: 'partial' },
    { label: 'たぶんいいえ', value: 'probably_no' },
    { label: 'わからない', value: 'unknown' }]

const answerLabelFor = (value: GekanatorAnswerValue): string =>
    answerOptions.find (option => option.value === value)?.label ?? value

const minQuestionsBeforeCertainGuess = 25
const hardMaxQuestions = 80
const winningRunQuestionLimit = 3
const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const recentGamesStorageKey = 'gekanator:recent-games:v1'
const maxStoredRecentGames = 12
const specialOriginalMonthDayLabels: Record<string, string> = {
    '1-1': '元日',
    '12-31': '大晦日',
    '12-3': '12月3日',
    '5-29': '5月29日' }
const mascotAssetByState: Record<MascotState, string> = {
    idle: '/assets/gekanator/mascot-idle.png',
    thinking_far: '/assets/gekanator/mascot-thinking-far.png',
    thinking_mid: '/assets/gekanator/mascot-thinking-mid.png',
    thinking_near: '/assets/gekanator/mascot-thinking-near.png',
    confident: '/assets/gekanator/mascot-confident.png',
    celebrate: '/assets/gekanator/mascot-celebrate.png',
    failed: '/assets/gekanator/mascot-failed.png' }
const mascotAltByState: Record<MascotState, string> = {
    idle: '待機する洗澡鹿',
    thinking_far: '遠くを見つめる洗澡鹿',
    thinking_mid: '考え込む洗澡鹿',
    thinking_near: '見通しが立ってきた洗澡鹿',
    confident: '見通した顔の洗澡鹿',
    celebrate: 'ご満悦の洗澡鹿',
    failed: 'しょんぼりした洗澡鹿' }

const sourcePriorityOffset = (question: GekanatorQuestion): number => {
  switch (question.source)
  {
  case 'user_suggested':
    return -1.2
  case 'admin_curated':
    return -0.8
  case 'ai_generated':
    return -0.6
  default:
    return 0
  }
}


const priorityWeightOffset = (question: GekanatorQuestion): number =>
    (Math.min (3, Math.max (.2, question.priorityWeight)) - 1) * -.8


const createGameSeed = (): string => {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
    return crypto.randomUUID ()

  return `${ Date.now () }:${ Math.random ().toString (36).slice (2) }`
}


const normalizeStoredQuestionId = (
    questionId:  string,
    condition?:  GekanatorQuestionCondition): string => {
  if (condition?.type === 'title-length-greater-than')
    return `title:length-at-least:${ condition.length + 1 }`

  if (questionId.startsWith ('title:length-greater-than:'))
    {
      const length = Number (questionId.split (':').pop ())
      if (Number.isInteger (length))
	return `title:length-at-least:${ length + 1 }`
    }

  return questionId
}


const normalizeStoredGame = (game: StoredGekanatorGame): StoredGekanatorGame => ({
  ...game,
  answers: game.answers.map (answer => {
    const questionMode =
	answer.questionMode === 'winning_run' || answer.questionMode === 'normal'
	? answer.questionMode
	: undefined
    const questionCondition =
	answer.questionCondition
	? normalizeTitleLengthCondition (answer.questionCondition)
	: undefined

    return {
	...answer,
	questionId: normalizeStoredQuestionId (answer.questionId,
					       answer.questionCondition),
	questionMode,
	questionCondition }
  }),
  askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
  softenedQuestionIds: (game.softenedQuestionIds
			.map (questionId => normalizeStoredQuestionId (questionId))),
  recoveredCandidatePosts: (game.recoveredCandidatePosts ?? []).map (item => ({
      ...item,
      scoreAtRecovery: item.scoreAtRecovery ?? (
	new Map (game.scores).get (item.postId) ?? 0) })),
  recoveryStepCount: game.recoveryStepCount ?? 0,
  winningRunTargetId: game.winningRunTargetId ?? null,
  winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? null,
  learnedExampleCount: game.learnedExampleCount ?? null,
  questionSuggestionEntryMode: game.questionSuggestionEntryMode ?? 'search',
  questionSuggestionSearch: game.questionSuggestionSearch ?? '',
  questionSuggestionSelectedId: game.questionSuggestionSelectedId ?? null,
  askedQuestionBank: game.askedQuestionBank?.map (question => ({
      ...question,
      id: normalizeStoredQuestionId (question.id, question.condition),
      condition: normalizeTitleLengthCondition (question.condition) })),
  askedQuestionBankIds: (
      game.askedQuestionBankIds?.map (questionId => normalizeStoredQuestionId (questionId))) })


const sourcePriorityForMerge = (question: GekanatorQuestion): number => {
  switch (question.source)
  {
  case 'user_suggested':
    return 3
  case 'admin_curated':
    return 3
  case 'ai_generated':
    return 3
  default:
    return 1
  }
}


const shouldReplaceMergedQuestion = (
    current:    GekanatorQuestion | undefined,
    candidate:  GekanatorQuestion): boolean => {
  if (!(current))
    return true

  const currentSourcePriority = sourcePriorityForMerge (current)
  const candidateSourcePriority = sourcePriorityForMerge (candidate)
  if (candidateSourcePriority !== currentSourcePriority)
    return candidateSourcePriority > currentSourcePriority

  if (candidate.priorityWeight !== current.priorityWeight)
    return candidate.priorityWeight > current.priorityWeight

  return true
}


const hashString = (value: string): number => {
  let hash = 2_166_136_261

  for (let i = 0; i < value.length; ++i)
    {
      hash ^= value.charCodeAt (i)
      hash = Math.imul (hash, 16_777_619)
    }

  return hash >>> 0
}


const deterministicUnitFloat = (seed: string): number => hashString (seed) / 4_294_967_295


const clearStoredGame = (): void => {
  try
  {
    sessionStorage.removeItem (gameStorageKey)
  }
  catch
  {
    return
  }
}


const loadStoredGame = (): StoredGekanatorGame | null => {
  try
  {
    const raw = sessionStorage.getItem (gameStorageKey)
    if (!(raw))
      return null

    return normalizeStoredGame (JSON.parse (raw) as StoredGekanatorGame)
  }
  catch
  {
    clearStoredGame ()
    return null
  }
}


const isStoredPhase = (phase: Phase): boolean => phase !== 'intro'


const loadRecentGames = (): RecentGameSummary[] => {
  try
  {
    const raw = localStorage.getItem (recentGamesStorageKey)
    if (!(raw))
      return []

    const parsed = JSON.parse (raw)
    if (!(Array.isArray (parsed)))
      return []

    return (
	parsed
	.filter ((item): item is RecentGameSummary => (
	    typeof item === 'object'
	    && item != null
	    && Number.isInteger ((item as RecentGameSummary).correctPostId)
	    && (((item as RecentGameSummary).firstQuestionId == null)
		|| typeof (item as RecentGameSummary).firstQuestionId === 'string')
	    && Number.isFinite ((item as RecentGameSummary).savedAt)))
	.sort ((a, b) => b.savedAt - a.savedAt)
	.slice (0, maxStoredRecentGames))
  }
  catch
  {
    return []
  }
}


const storeRecentGameSummary = (
    summary:  RecentGameSummary): RecentGameSummary[] => {
  const next =
      [summary,
       ...loadRecentGames ().filter (item => (item.savedAt !== summary.savedAt
					      && !(item.correctPostId === summary.correctPostId
						   && (item.firstQuestionId
						       === summary.firstQuestionId))))]
      .slice (0, maxStoredRecentGames)

  try
  {
    localStorage.setItem (recentGamesStorageKey, JSON.stringify (next))
  }
  catch
  {
    return next
  }

  return next
}


const loadBackgroundMotionMode = (): BackgroundMotionMode => {
  try
  {
    return getClientGekanatorBackgroundMotion ()
  }
  catch
  {
    return 'on'
  }
}


const resettableExtraQuestionState = (): {
    extraQuestions:        GekanatorExtraQuestion[]
    extraQuestionAnswers:  Record<string, GekanatorAnswerValue>
    extraQuestionState:    'idle' } => (
	{ extraQuestions: [],
	  extraQuestionAnswers: { },
	  extraQuestionState: 'idle' })


const recoveredCandidateMapFromStored = (
    items:   RecoveredCandidatePost[],
    scores:  [number, number][]): Map<number, RecoveredCandidateState> => {
  const storedScores = new Map (scores)

  return new Map (items.map (item => [item.postId, {
      answerCountAtRecovery: item.answerCountAtRecovery,
      scoreAtRecovery: item.scoreAtRecovery ?? storedScores.get (item.postId) ?? 0 }]))
}


const storedRecoveredCandidatesFromMap = (
    recoveredCandidatePosts: Map<number, RecoveredCandidateState>): RecoveredCandidatePost[] =>
    [...recoveredCandidatePosts.entries ()]
    .map (([postId, recoveredCandidate]) => ({
	postId,
	answerCountAtRecovery: recoveredCandidate.answerCountAtRecovery,
	scoreAtRecovery: recoveredCandidate.scoreAtRecovery }))


const learnedSemanticMinKnownRatio = .08
const learnedSemanticMinKnownCount = 4
const learnedSemanticMinSideCount = 2
const targetEffectiveUserSuggestedQuestionRatio = 1 / 3
const targetLearningUserSuggestedQuestionRatio = 1 / 3
const targetTotalUserSuggestedQuestionRatio = 2 / 3
const scoreDropActivationThreshold = 20
const learningUserSuggestedScoreWeight = .33


const baseDeltaForAnswer = (answer: GekanatorAnswerValue): number => {
  switch (answer)
  {
  case 'yes':
    return 4
  case 'no':
    return -4
  case 'partial':
    return 2
  case 'probably_no':
    return -2
  case 'unknown':
    return 0
  }
}


const distributionEntropy = (weights: number[]): number =>
    weights.reduce ((sum, weight) => weight <= 0 ? sum : sum - weight * Math.log2 (weight), 0)


const questionCategoryPenalty = (
    question:       GekanatorQuestion,
    answerCount:    number,
    repeatPenalty:  number): number => {
  const earlyFactor = Math.max (0, (3 - answerCount) / 3)
  const titleLengthPenalty = (() => {
    if (titleLengthMinimumForCondition (question.condition) == null)
      return 0

    return (answerCount === 0 ? 8 : 3.5) * earlyFactor
  }) ()

  switch (question.kind)
  {
  case 'tag':
    return -2.8 * earlyFactor + repeatPenalty
  case 'post_similarity':
    return -3.2 * earlyFactor + repeatPenalty
  case 'title':
    return 3.4 * earlyFactor + titleLengthPenalty + repeatPenalty
  case 'source':
  case 'original_date':
    return 2.4 * earlyFactor + repeatPenalty
  default:
    return repeatPenalty
  }
}


const relatedPostIdsOf = (post: Post): number[] => {
  const siblingPosts = Object.values (post.siblingPosts ?? { }).flat ()

  return [...new Set ([
	  ...(post.related ?? []).map (related => related.id),
	  ...(post.parentPosts ?? []).map (parent => parent.id),
	  ...(post.childPosts ?? []).map (child => child.id),
	  ...siblingPosts.map (sibling => sibling.id)])]
}


const userPriorWeightsFor = (
    posts:        Post[],
    recentGames:  RecentGameSummary[]): Map<number, number> => {
  const postById = new Map (posts.map (post => [post.id, post]))
  const weights = new Map<number, number> ()
  const addWeight = (postId: number, weight: number) => {
    if (!(postById.has (postId)) || weight <= 0)
      return

    weights.set (postId, (weights.get (postId) ?? 0) + weight)
  }

  recentGames.slice (0, 6).forEach ((game, index) => {
    const baseWeight = Math.max (.24, 1 - index * .18)
    addWeight (game.correctPostId, baseWeight)

    const correctPost = postById.get (game.correctPostId)
    if (!(correctPost))
      return

    relatedPostIdsOf (correctPost).forEach (postId => addWeight (postId, baseWeight * .45))
  })

  return weights
}


const answerWeightFor = (
    questionId:           string,
    softenedQuestionIds:  Set<string>): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 1


const scoreWeightForAnswer = (
    answer:               GekanatorAnswerLog,
    softenedQuestionIds:  Set<string>): number =>
    answerWeightFor (answer.questionId, softenedQuestionIds)
    * (
	answer.questionPurpose === 'learning_user_suggested'
	? learningUserSuggestedScoreWeight
	: 1)


const questionDifficulty = (question: GekanatorQuestion): number => {
  if (question.kind === 'source')
    return 4
  if (question.kind === 'original_date')
    return 4
  if (question.kind === 'title')
    return 4
  if (question.kind === 'tag')
    return 3

  return 1
}


type GekanatorMatchIndex = Map<string, Set<number>>

type GekanatorQuestionMaterialIndex = {
    postById:                    Map<number, Post>
    tagKeysByPostId:             Map<number, string[]>
    postIdsByTagKey:             Map<string, Set<number>>
    titleTermsByPostId:          Map<number, string[]>
    postIdsByTitleTerm:          Map<string, Set<number>>
    hostByPostId:                Map<number, string | null>
    postIdsByHost:               Map<string, Set<number>>
    originalYearByPostId:        Map<number, number | null>
    postIdsByOriginalYear:       Map<number, Set<number>>
    originalMonthByPostId:       Map<number, number | null>
    postIdsByOriginalMonth:      Map<number, Set<number>>
    originalMonthDayByPostId:    Map<number, string | null>
    postIdsByOriginalMonthDay:   Map<string, Set<number>>
    titleLengthByPostId:         Map<number, number>
    titleAsciiPostIds:           Set<number>
    titleLengthThresholdCache:   Map<number, Set<number>>
}

const titleTermPattern =
    /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}A-Za-z0-9]{2,}/gu


const addPostIdToIndex = <K extends string | number> (
    index:   Map<K, Set<number>>,
    key:     K,
    postId:  number) => {
  const current = index.get (key)
  if (current)
    {
      current.add (postId)
      return
    }

  index.set (key, new Set ([postId]))
}


const buildMaterialIndex = (
    posts:  Post[]): GekanatorQuestionMaterialIndex => {
  const postById = new Map<number, Post> ()
  const tagKeysByPostId = new Map<number, string[]> ()
  const postIdsByTagKey = new Map<string, Set<number>> ()
  const titleTermsByPostId = new Map<number, string[]> ()
  const postIdsByTitleTerm = new Map<string, Set<number>> ()
  const hostByPostId = new Map<number, string | null> ()
  const postIdsByHost = new Map<string, Set<number>> ()
  const originalYearByPostId = new Map<number, number | null> ()
  const postIdsByOriginalYear = new Map<number, Set<number>> ()
  const originalMonthByPostId = new Map<number, number | null> ()
  const postIdsByOriginalMonth = new Map<number, Set<number>> ()
  const originalMonthDayByPostId = new Map<number, string | null> ()
  const postIdsByOriginalMonthDay = new Map<string, Set<number>> ()
  const titleLengthByPostId = new Map<number, number> ()
  const titleAsciiPostIds = new Set<number> ()

  posts.forEach (post => {
    postById.set (post.id, post)

    const tagKeys = post.tags
	.filter (tag =>
	    tag.category !== 'meta'
	    && !(tag.name.includes ('タグ希望'))
	    && !(tag.name.includes ('bot操作')))
	.map (tag => `${ tag.category }:${ tag.name }`)
    tagKeysByPostId.set (post.id, tagKeys)
    tagKeys.forEach (key => addPostIdToIndex (postIdsByTagKey, key, post.id))

    const titleTerms = Array.from (
	new Set ((post.title ?? '').match (titleTermPattern) ?? []))
    titleTermsByPostId.set (post.id, titleTerms)
    titleTerms.forEach (term => addPostIdToIndex (postIdsByTitleTerm, term, post.id))

    const host = (() => {
      try
      {
	return new URL (post.url).hostname.replace (/^www\./, '')
      }
      catch
      {
	return null
      }
    }) ()
    hostByPostId.set (post.id, host)
    if (host)
      addPostIdToIndex (postIdsByHost, host, post.id)

    const originalValue = post.originalCreatedFrom || post.originalCreatedBefore
    const date =
	originalValue
	? new Date (originalValue)
	: null
    const validDate =
	date && !(Number.isNaN (date.getTime ()))
	? date
	: null
    const originalYear = validDate?.getFullYear () ?? null
    const originalMonth =
	validDate
	? validDate.getMonth () + 1
	: null
    const originalMonthDay =
	validDate
	? `${ validDate.getMonth () + 1 }-${ validDate.getDate () }`
	: null
    originalYearByPostId.set (post.id, originalYear)
    originalMonthByPostId.set (post.id, originalMonth)
    originalMonthDayByPostId.set (post.id, originalMonthDay)
    if (originalYear != null)
      addPostIdToIndex (postIdsByOriginalYear, originalYear, post.id)
    if (originalMonth != null)
      addPostIdToIndex (postIdsByOriginalMonth, originalMonth, post.id)
    if (originalMonthDay != null)
      addPostIdToIndex (postIdsByOriginalMonthDay, originalMonthDay, post.id)

    const titleLength = post.title?.length ?? 0
    titleLengthByPostId.set (post.id, titleLength)
    if (/[A-Za-z0-9]/.test (post.title ?? ''))
      titleAsciiPostIds.add (post.id)
  })

  return {
      postById,
      tagKeysByPostId,
      postIdsByTagKey,
      titleTermsByPostId,
      postIdsByTitleTerm,
      hostByPostId,
      postIdsByHost,
      originalYearByPostId,
      postIdsByOriginalYear,
      originalMonthByPostId,
      postIdsByOriginalMonth,
      originalMonthDayByPostId,
      postIdsByOriginalMonthDay,
      titleLengthByPostId,
      titleAsciiPostIds,
      titleLengthThresholdCache: new Map<number, Set<number>> () }
}

const indexedQuestionTextForTag = (key: string): string => {
  const [category, ...rest] = key.split (':')
  const name = rest.join (':')
  const label = category === 'nico' ? name.replace (/^nico:/, '') : name

  switch (category)
  {
  case 'deerjikist':
    return `${ label }（敬称略）がコンテンツ作成に関与した?`
  case 'meme':
    return `『${ label }』に関係しそう?`
  case 'character':
    return `${ label }が登場する?`
  case 'material':
    return `${ label }が使われている?`
  case 'nico':
    return `ニコニコに「${ label }」というタグがついている?`
  default:
    return `${ label }の要素が含まれる?`
  }
}

const specialOriginalMonthDayLabelFor = (monthDay: string): string | null =>
    specialOriginalMonthDayLabels[monthDay] ?? null


const originalDateQuestionTextFor = (
    condition:  Extract<
	GekanatorQuestionCondition,
	{ type: 'original-year' | 'original-month' | 'original-month-day' }
    >): string => {
  switch (condition.type)
  {
  case 'original-year':
    return `オリジナルの投稿年は ${ condition.year } 年?`
  case 'original-month':
    return `オリジナルの投稿月は ${ condition.month } 月?`
  case 'original-month-day':
    {
      const label = specialOriginalMonthDayLabelFor (condition.monthDay)
      if (label)
	return `${ label }に投稿された?`

      const [month, day] = condition.monthDay.split ('-')
      return `オリジナルの投稿日は ${ month } 月 ${ day } 日?`
    }
  }
}


const humanPriorityOffsetFor = (question: GekanatorQuestion): number => {
  switch (question.kind)
  {
  case 'tag':
    return -6
  case 'source':
    return -2.5
  case 'post_similarity':
    if (question.source === 'user_suggested' || question.source === 'admin_curated')
      return -3.5

    return -1.5
  case 'original_date':
    switch (question.condition.type)
    {
    case 'original-year':
      return -2
    case 'original-month-day':
      return specialOriginalMonthDayLabelFor (question.condition.monthDay) ? -1.4 : 6
    case 'original-month':
      return 7
    default:
      return 0
    }
  case 'title':
    switch (question.condition.type)
    {
    case 'title-contains':
      return -1.8
    case 'title-has-ascii':
      return 10
    case 'title-length-at-least':
    case 'title-length-greater-than':
      return 9
    default:
      return 0
    }
  default:
    return 0
  }
}


const isLearnableTagKey = (key: string): boolean => !(key.startsWith ('nico:'))


const isUserSuggestedLearnedSemanticQuestion = (
    question:  GekanatorQuestion): boolean => isLearnedSemanticQuestion (question)


type LearnedSemanticCandidateStats = {
    positiveIds:    Set<number>
    negativeIds:    Set<number>
    unknownIds:     Set<number>
    positiveCount:  number
    negativeCount:  number
    unknownCount:   number
    knownCount:     number }


const learnedSemanticStatsForCandidateIds = (
    { candidateIds,
      posts,
      question }: {
	candidateIds:  number[]
	posts:         Post[]
	question:      GekanatorQuestion }): LearnedSemanticCandidateStats => {
  const candidateIdSet = new Set (candidateIds)
  const positiveIds = new Set<number> ()
  const negativeIds = new Set<number> ()
  const unknownIds = new Set<number> ()

  posts.forEach (post => {
    if (!(candidateIdSet.has (post.id)))
      return

    const side = learnedSemanticSideForPost (question, post)
    if (side === 'positive')
      {
	positiveIds.add (post.id)
	return
      }
    if (side === 'negative')
      {
	negativeIds.add (post.id)
	return
      }

    unknownIds.add (post.id)
  })

  return {
      positiveIds,
      negativeIds,
      unknownIds,
      positiveCount: positiveIds.size,
      negativeCount: negativeIds.size,
      unknownCount: unknownIds.size,
      knownCount: positiveIds.size + negativeIds.size }
}


const learnedSemanticQuestionIsEffectiveForCandidateIds = (
    { candidateIds,
      posts,
      question }: {
	candidateIds:  number[]
	posts:         Post[]
	question:      GekanatorQuestion }): boolean => {
  if (!(isUserSuggestedLearnedSemanticQuestion (question)))
    return false

  const stats = learnedSemanticStatsForCandidateIds ({
      candidateIds,
      posts,
      question })
  const minimumKnownCount = Math.max (
      learnedSemanticMinKnownCount,
      Math.floor (candidateIds.length * learnedSemanticMinKnownRatio))

  return stats.knownCount >= minimumKnownCount
      && stats.positiveCount >= learnedSemanticMinSideCount
      && stats.negativeCount >= learnedSemanticMinSideCount
}


const directSemanticAnswerForPost = (
    question:  GekanatorQuestion,
    post:      Post): GekanatorAnswerValue | null => {
  const direct = question.exampleAnswers?.[String (post.id) as `${ number }`]
  if (direct)
    return direct
  if (question.condition.type === 'post-similarity' && question.condition.postId === post.id)
    return question.condition.answer

  return null
}


const learnedSemanticLearningValueForTopPosts = (
    { question,
      learningTargetPosts,
      candidateIds,
      posts }: {
	question:      GekanatorQuestion
	learningTargetPosts: Post[]
	candidateIds:  number[]
	posts:         Post[] }): { missingTopCount: number
     knownCount:      number
     hasLearningValue: boolean } => {
  const missingTopCount =
      learningTargetPosts.filter (
	  post => directSemanticAnswerForPost (question, post) == null).length
  const knownCount = learnedSemanticStatsForCandidateIds ({
      candidateIds,
      posts,
      question }).knownCount

  return {
      missingTopCount,
      knownCount,
      hasLearningValue: missingTopCount > 0 }
}


const learningTargetPostsForCandidates = ({
    scoredPosts,
    gameSeed,
}: {
    scoredPosts:  { post: Post; score: number }[]
    gameSeed:     string
}): Post[] => {
  const topPosts = scoredPosts
      .slice (0, Math.min (6, scoredPosts.length))
      .map (item => item.post)
  const topPostIds = new Set (topPosts.map (post => post.id))
  const sampledPosts = scoredPosts
      .filter (item => !(topPostIds.has (item.post.id)))
      .map (item => ({
	  post: item.post,
	  weight: deterministicUnitFloat (`${ gameSeed }:learning-sample:${ item.post.id }`) }))
      .sort ((a, b) => a.weight - b.weight)
      .slice (0, Math.min (4, Math.max (0, scoredPosts.length - topPosts.length)))
      .map (item => item.post)

  return [...topPosts, ...sampledPosts]
}


const questionPurposeCountsFor = (
    answers:  GekanatorAnswerLog[]): {
    effectiveUserSuggestedCount: number
    learningUserSuggestedCount:  number
    normalQuestionCount:         number
    totalNormalPhaseQuestionCount: number
    totalUserSuggestedCount:     number } => {
  let effectiveUserSuggestedCount = 0
  let learningUserSuggestedCount = 0
  let normalQuestionCount = 0

  answers.forEach (answer => {
    if (answer.questionMode !== 'normal')
      return

    switch (answer.questionPurpose)
    {
    case 'effective_user_suggested':
      ++effectiveUserSuggestedCount
      return
    case 'learning_user_suggested':
      ++learningUserSuggestedCount
      return
    default:
      ++normalQuestionCount
    }
  })

  return {
      effectiveUserSuggestedCount,
      learningUserSuggestedCount,
      normalQuestionCount,
      totalNormalPhaseQuestionCount:
	  effectiveUserSuggestedCount + learningUserSuggestedCount + normalQuestionCount,
      totalUserSuggestedCount:
	  effectiveUserSuggestedCount + learningUserSuggestedCount }
}


const learnedSemanticNarrowPenaltyForStats = (
    candidateCount:  number,
    stats:           LearnedSemanticCandidateStats): number => {
  const minSide = candidateCount < 10 ? 1 : Math.max (3, candidateCount * .08)
  return stats.positiveCount < minSide || stats.negativeCount < minSide ? .15 : 0
}


const learnedSemanticScoreDeltaForExpectedAnswer = (
    userAnswer:      GekanatorAnswerValue,
    expectedAnswer:  GekanatorAnswerValue | null): number => {
  switch (userAnswer)
  {
  case 'yes':
    if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
      return 4
    if (expectedAnswer === 'no' || expectedAnswer === 'probably_no')
      return -4
    return 0
  case 'no':
    if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
      return -4
    if (expectedAnswer === 'no' || expectedAnswer === 'probably_no')
      return 4
    return 0
  case 'partial':
    if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
      return 2
    if (expectedAnswer === 'no')
      return -2
    if (expectedAnswer === 'probably_no')
      return -1
    return 0
  case 'probably_no':
    if (expectedAnswer === 'yes' || expectedAnswer === 'partial')
      return -2
    if (expectedAnswer === 'no' || expectedAnswer === 'probably_no')
      return 1
    return 0
  case 'unknown':
    return 0
  }
}


const scoreDropDeltaForRecoveredPost = (
    postId:                   number,
    totalScore:               number,
    recoveredCandidatePosts:  Map<number, RecoveredCandidateState>): number => {
  const recoveredCandidate = recoveredCandidatePosts.get (postId)
  if (recoveredCandidate == null)
    return totalScore

  return totalScore - recoveredCandidate.scoreAtRecovery
}


const activeCandidateScoreDropEnabled = (scores: Map<number, number>): boolean => {
  if (scores.size === 0)
    return false

  const maxScore = Math.max (...scores.values ())
  return maxScore >= scoreDropActivationThreshold
}


const postPassesScoreDrop = (
    { postId,
      scores,
      recoveredCandidatePosts }: {
	postId:                   number
	scores:                   Map<number, number>
	recoveredCandidatePosts:  Map<number, RecoveredCandidateState> }): boolean => {
  if (!(activeCandidateScoreDropEnabled (scores)))
    return true

  const totalScore = scores.get (postId) ?? 0
  return scoreDropDeltaForRecoveredPost (
      postId,
      totalScore,
      recoveredCandidatePosts) >= 0
}


// `post_similarities` is the score-propagation graph, not the question kind.
const questionUsesPostSimilarityPropagationGraphForScoring = (
    question:  GekanatorQuestion): boolean =>
    (question.kind === 'post_similarity'
     && !(isUserSuggestedLearnedSemanticQuestion (question)))
      || (question.kind === 'tag'
	  && question.condition.type === 'tag'
	  && !(question.condition.key.startsWith ('nico:')))


const questionSupportsAnswerBasedHardFiltering = (
    question:  GekanatorQuestion): boolean => !(questionUsesPostSimilarityPropagationGraphForScoring (question))
    && !(isUserSuggestedLearnedSemanticQuestion (question))


const isLearnableQuestionForUserAnswer = (question: GekanatorQuestion): boolean =>
    question.kind === 'post_similarity'
      || (question.kind === 'tag'
	  && question.condition.type === 'tag'
	  && isLearnableTagKey (question.condition.key))


const usesLearnedTagExamples = (question: GekanatorQuestion): boolean =>
    question.kind === 'tag'
      && question.condition.type === 'tag'
      && Boolean (question.exampleAnswers)
      && Object.keys (question.exampleAnswers ?? { }).length > 0


const searchedQuestionsFor = (
    questions:  GekanatorQuestion[],
    search:     string): GekanatorQuestion[] => {
  const needle = search.trim ()
  if (!(needle))
    return []

  const normalizedNeedle = needle.toLowerCase ()
  const prefixMatches = questions.filter (question =>
      isLearnableQuestionForUserAnswer (question)
      && question.text.toLowerCase ().startsWith (normalizedNeedle))
  const partialMatches = questions.filter (question =>
      isLearnableQuestionForUserAnswer (question)
      && !(question.text.toLowerCase ().startsWith (normalizedNeedle))
      && question.text.toLowerCase ().includes (normalizedNeedle))

  return [...prefixMatches, ...partialMatches].slice (0, 20)
}

const matchingPostIdsForCondition = ({
    condition,
    materialIndex,
}: {
    condition:      GekanatorQuestionCondition
    materialIndex:  GekanatorQuestionMaterialIndex
}): Set<number> | null => {
  switch (condition.type)
  {
  case 'tag':
    return materialIndex.postIdsByTagKey.get (condition.key) ?? new Set<number> ()
  case 'source':
    return materialIndex.postIdsByHost.get (condition.host) ?? new Set<number> ()
  case 'original-year':
    return materialIndex.postIdsByOriginalYear.get (condition.year) ?? new Set<number> ()
  case 'original-month':
    return materialIndex.postIdsByOriginalMonth.get (condition.month) ?? new Set<number> ()
  case 'original-month-day':
    return materialIndex.postIdsByOriginalMonthDay.get (condition.monthDay) ?? new Set<number> ()
  case 'title-has-ascii':
    return materialIndex.titleAsciiPostIds
  case 'title-contains':
    return materialIndex.postIdsByTitleTerm.get (condition.text) ?? new Set<number> ()
  case 'title-length-at-least':
  case 'title-length-greater-than': {
    const threshold =
	titleLengthMinimumForCondition (condition)
    if (threshold == null)
      return new Set<number> ()

    const cached = materialIndex.titleLengthThresholdCache.get (threshold)
    if (cached)
      return cached

    const matched = new Set<number> ()
    materialIndex.titleLengthByPostId.forEach ((length, postId) => {
      if (length >= threshold)
	matched.add (postId)
    })
    materialIndex.titleLengthThresholdCache.set (threshold, matched)
    return matched
  }
  case 'post-similarity':
    return null
  }
}

type QuestionMatchResolver = {
    posts:              Post[]
    materialIndex:      GekanatorQuestionMaterialIndex
    matchIndex:         GekanatorMatchIndex
    question:           GekanatorQuestion
    dynamicMatchIndex?: GekanatorMatchIndex
}

const buildGekanatorMatchIndex = (
    posts:      Post[],
    questions:  GekanatorQuestion[]): GekanatorMatchIndex => new Map (
    questions.map (question => [
	question.id,
	new Set (
	    posts
		.filter (post => question.test (post))
		.map (post => post.id))]))

const matchingPostIdsForQuestion = ({
    posts,
    materialIndex,
    matchIndex,
    question,
    dynamicMatchIndex,
}: QuestionMatchResolver): Set<number> => {
  if (usesLearnedTagExamples (question))
    {
      const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id)
      if (matched)
	return matched

      const computed = new Set (
	  posts
	      .filter (post => question.test (post))
	      .map (post => post.id))
      dynamicMatchIndex?.set (question.id, computed)
      return computed
    }

  const byCondition = matchingPostIdsForCondition ({
      condition: question.condition,
      materialIndex })
  if (byCondition != null)
    return byCondition

  const matched = matchIndex.get (question.id) ?? dynamicMatchIndex?.get (question.id)
  if (matched)
    return matched

  const computed = new Set (
      posts
	  .filter (post => question.test (post))
	  .map (post => post.id))
  dynamicMatchIndex?.set (question.id, computed)
  return computed
}


const positiveMatchingPostIdsForQuestion = (
    resolver:  QuestionMatchResolver): Set<number> => {
  if (isUserSuggestedLearnedSemanticQuestion (resolver.question))
    {
      const cached = resolver.dynamicMatchIndex?.get (resolver.question.id)
      if (cached)
	return cached

      const computed = new Set (
	  resolver.posts
	      .filter (post =>
		  learnedSemanticSideForPost (resolver.question, post) === 'positive')
	      .map (post => post.id))
      resolver.dynamicMatchIndex?.set (resolver.question.id, computed)
      return computed
    }

  return matchingPostIdsForQuestion (resolver)
}

const matchingPostCountInIds = ({
    candidateIds,
    candidateIdSet,
    posts,
    materialIndex,
    matchIndex,
    question,
    dynamicMatchIndex,
}: {
    candidateIds:       number[]
    candidateIdSet?:    Set<number>
    posts:              Post[]
    materialIndex:      GekanatorQuestionMaterialIndex
    matchIndex:         GekanatorMatchIndex
    question:           GekanatorQuestion
    dynamicMatchIndex?: GekanatorMatchIndex
}): number => {
  const matched = positiveMatchingPostIdsForQuestion ({
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex })
  const ids = candidateIdSet ?? new Set (candidateIds)
  let count = 0

  if (matched.size < ids.size)
    matched.forEach (postId => {
      if (ids.has (postId))
	++count
    })
  else
    ids.forEach (postId => {
      if (matched.has (postId))
	++count
    })

  return count
}

const matchingWeightInCandidates = (
    { candidates,
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex }: { candidates:          { post: Post; weight: number }[]
			     posts:               Post[]
			     materialIndex:       GekanatorQuestionMaterialIndex
			     matchIndex:          GekanatorMatchIndex
			     question:            GekanatorQuestion
			     dynamicMatchIndex?:  GekanatorMatchIndex }): number => {
  const matched = positiveMatchingPostIdsForQuestion ({
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex })

  return candidates.reduce ((sum, item) =>
      sum + (matched.has (item.post.id) ? item.weight : 0), 0)
}

const signatureForCandidateIds = (
    { candidateIds,
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex, }: { candidateIds:       number[]
			      posts:              Post[]
			      materialIndex:      GekanatorQuestionMaterialIndex
			      matchIndex:         GekanatorMatchIndex
			      question:           GekanatorQuestion
			      dynamicMatchIndex?: GekanatorMatchIndex }): string => {
  if (isUserSuggestedLearnedSemanticQuestion (question))
    {
      const postById = new Map (posts.map (post => [post.id, post]))

      return candidateIds.map (postId => {
	const post = postById.get (postId) ?? null
	const side = learnedSemanticSideForPost (question, post)
	if (side === 'positive')
	  return '1'
	if (side === 'negative')
	  return '0'

	return 'u'
      }).join ('')
    }

  const matched = positiveMatchingPostIdsForQuestion ({
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex })

  return candidateIds.map (postId => matched.has (postId) ? '1' : '0').join ('')
}

const postIdsForHardAnswer = (
    { candidateIds,
      question,
      answer,
      posts,
      materialIndex,
      matchIndex,
      dynamicMatchIndex }: { candidateIds:        number[]
			     question:            GekanatorQuestion
			     answer:              GekanatorAnswerValue
			     posts:               Post[]
			     materialIndex:       GekanatorQuestionMaterialIndex
			     matchIndex:          GekanatorMatchIndex
			     dynamicMatchIndex?:  GekanatorMatchIndex }): number[] => {
  if (!(questionSupportsAnswerBasedHardFiltering (question)))
    return candidateIds

  if (answer === 'unknown'
      || answer === 'partial'
      || answer === 'probably_no')
    return candidateIds

  if (answer === 'yes')
    {
      const matched = positiveMatchingPostIdsForQuestion ({
	  posts,
	  materialIndex,
	  matchIndex,
	  question,
	  dynamicMatchIndex })
      return candidateIds.filter (postId => matched.has (postId))
    }

  if (answer === 'no')
    {
      const matched = positiveMatchingPostIdsForQuestion ({
	  posts,
	  materialIndex,
	  matchIndex,
	  question,
	  dynamicMatchIndex })
      return candidateIds.filter (postId => !(matched.has (postId)))
    }

  return candidateIds
}


const applyQuestionAnswerDeltaToScores = ({
    posts,
    question,
    answer,
    weight,
    nextScores,
    materialIndex,
    matchIndex,
    dynamicMatchIndex,
}: {
    posts:              Post[]
    question:           GekanatorQuestion
    answer:             GekanatorAnswerValue
    weight:             number
    nextScores:         Map<number, number>
    materialIndex:      GekanatorQuestionMaterialIndex
    matchIndex:         GekanatorMatchIndex
    dynamicMatchIndex:  GekanatorMatchIndex
}): void => {
  if (isUserSuggestedLearnedSemanticQuestion (question))
    {
      posts.forEach (post => {
	const delta = learnedSemanticScoreDeltaForExpectedAnswer (
	    answer,
	    expectedAnswerForQuestion (question, post))
	if (delta === 0)
	  return

	nextScores.set (
	    post.id,
	    (nextScores.get (post.id) ?? 0) + delta * weight)
      })
      return
    }

  const baseDelta = baseDeltaForAnswer (answer) * weight
  if (baseDelta === 0)
    return

  if (!(questionUsesPostSimilarityPropagationGraphForScoring (question)))
    {
      posts.forEach (post => {
	const delta = learnedSemanticScoreDeltaForExpectedAnswer (
	    answer,
	    expectedAnswerForQuestion (question, post))
	if (delta === 0)
	  return

	nextScores.set (
	    post.id,
	    (nextScores.get (post.id) ?? 0) + delta * weight)
      })
      return
    }

  const matched = positiveMatchingPostIdsForQuestion ({
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex })

  matched.forEach (postId => {
    nextScores.set (
	postId,
	(nextScores.get (postId) ?? 0) + baseDelta)
  })

  // `post_similarities` is the propagation graph. Directly matched posts
  // get only the base delta; only non-direct neighbors get `base delta * cos`.
  // When several matched posts point at the same neighbor, keep the largest
  // absolute propagated contribution instead of summing all of them.
  const propagatedDeltaByPostId = new Map<number, number> ()
  matched.forEach (postId => {
    const post = materialIndex.postById.get (postId)
    post?.postSimilarityEdges?.forEach (edge => {
      if (!Number.isFinite (edge.cos) || edge.cos <= 0)
	return
      if (matched.has (edge.targetPostId))
	return

      const propagatedDelta = baseDelta * edge.cos
      const current = propagatedDeltaByPostId.get (edge.targetPostId)
      if (current == null || Math.abs (propagatedDelta) > Math.abs (current))
	propagatedDeltaByPostId.set (edge.targetPostId, propagatedDelta)
    })
  })

  propagatedDeltaByPostId.forEach ((propagatedDelta, postId) => {
    nextScores.set (
	postId,
	(nextScores.get (postId) ?? 0) + propagatedDelta)
  })
}

const buildIndexedQuestion = (
    { condition,
      text,
      kind,
      priorityWeight,
      materialIndex }: {
	condition:      Exclude<GekanatorQuestionCondition, { type: 'post-similarity' }>
	text:           string
	kind:           GekanatorQuestionKind
	priorityWeight: number
	materialIndex:  GekanatorQuestionMaterialIndex }): GekanatorQuestion => ({
    id: questionIdForCondition (condition),
    text,
    kind,
    condition,
    source: 'default',
    priorityWeight,
    test: post =>
	(matchingPostIdsForCondition ({
	    condition,
	    materialIndex }) ?? new Set<number> ()).has (post.id) })

const rankedEntriesForCounts = <T extends string | number> (
    { counts, total, cap }: { counts:  Map<T, number>
			      total:   number
    			      cap:     number }): [T, number][] =>
    ([...counts.entries ()]
     .filter (([, count]) => count > 0 && count < total)
     .sort ((a, b) => Math.abs (total / 2 - a[1]) - Math.abs (total / 2 - b[1]))
     .slice (0, cap))

const buildQuestionsForCandidateIds = (
    { candidateIds,
      materialIndex,
      acceptedQuestions,
      mode = 'split',
      confirmationPostId = null }: { candidateIds:      number[]
				     materialIndex:     GekanatorQuestionMaterialIndex
				     acceptedQuestions: GekanatorQuestion[]
				     mode?:            QuestionBuildMode
				     confirmationPostId?: number | null }): GekanatorQuestion[] => {
  const total = candidateIds.length
  const confirmationPost = (() => {
    if (confirmationPostId == null)
      return null

    return materialIndex.postById.get (confirmationPostId) ?? null
  }) ()

  if (mode === 'split' && total === 0)
    return acceptedQuestions
  if (mode === 'confirmation' && confirmationPost == null)
    return acceptedQuestions

  const tagCounts = new Map<string, number> ()
  const hostCounts = new Map<string, number> ()
  const yearCounts = new Map<number, number> ()
  const monthDayCounts = new Map<string, number> ()
  const titleTermCounts = new Map<string, number> ()
  const titleLengths: number[] = []
  let asciiCount = 0

  candidateIds.forEach (postId => {
    materialIndex.tagKeysByPostId.get (postId)?.forEach (key =>
	tagCounts.set (key, (tagCounts.get (key) ?? 0) + 1))
    const host = materialIndex.hostByPostId.get (postId)
    if (host)
      hostCounts.set (host, (hostCounts.get (host) ?? 0) + 1)
    const year = materialIndex.originalYearByPostId.get (postId)
    if (year != null)
      yearCounts.set (year, (yearCounts.get (year) ?? 0) + 1)
    const monthDay = materialIndex.originalMonthDayByPostId.get (postId)
    if (monthDay)
      monthDayCounts.set (monthDay, (monthDayCounts.get (monthDay) ?? 0) + 1)
    materialIndex.titleTermsByPostId.get (postId)?.forEach (term =>
	titleTermCounts.set (term, (titleTermCounts.get (term) ?? 0) + 1))
    const titleLength = materialIndex.titleLengthByPostId.get (postId) ?? 0
    titleLengths.push (titleLength)
    if (materialIndex.titleAsciiPostIds.has (postId))
      ++asciiCount
  })

  const tagCap = total >= 120 ? 128 : 96
  const titleTermCap = total >= 80 ? 10 : total >= 24 ? 14 : 20
  const factCap = total >= 80 ? 8 : 12
  const sortedLengths = [...titleLengths].sort ((a, b) => a - b)
  const titleLengthMedian = sortedLengths[Math.floor (sortedLengths.length / 2)] ?? 0

  const questions: GekanatorQuestion[] = []
  const addQuestion = (question: GekanatorQuestion | null) => {
    if (question)
      questions.push (question)
  }
  const buildDateQuestion = (
      condition: Extract<
	  GekanatorQuestionCondition,
	  { type: 'original-year' | 'original-month' | 'original-month-day' }
      >): GekanatorQuestion => {
    const priorityWeight = (() => {
      if (condition.type === 'original-year')
	return 1.04
      if (condition.type === 'original-month-day')
	return 1.01

      return .92
    }) ()

    return buildIndexedQuestion ({
      condition,
      text: originalDateQuestionTextFor (condition),
      kind: 'original_date',
      priorityWeight,
      materialIndex })
  }
  const specialMonthDays = rankedEntriesForCounts ({
      counts: monthDayCounts,
      total,
      cap: factCap}).filter (([monthDay]) => specialOriginalMonthDayLabelFor (String (monthDay)) != null)

  if (mode === 'split')
    {
      rankedEntriesForCounts ({ counts: tagCounts, total, cap: tagCap })
	  .forEach (([key]) => {
	    addQuestion (buildIndexedQuestion ({
		condition: { type: 'tag', key },
		text: indexedQuestionTextForTag (key),
		kind: 'tag',
		priorityWeight: 1.08,
		materialIndex }))
	  })

      rankedEntriesForCounts ({ counts: hostCounts, total, cap: factCap })
	  .forEach (([host]) => {
	    addQuestion (buildIndexedQuestion ({
		condition: { type: 'source', host },
		text: `${ host } の投稿を思い浮かべている?`,
		kind: 'source',
		priorityWeight: 1.02,
		materialIndex }))
	  })

      rankedEntriesForCounts ({ counts: yearCounts, total, cap: factCap })
	  .forEach (([year]) => {
	    addQuestion (buildDateQuestion ({
		type: 'original-year',
		year }))
	  })

      specialMonthDays.forEach (([monthDay]) => {
	addQuestion (buildDateQuestion ({
	    type: 'original-month-day',
	    monthDay: String (monthDay) }))
      })

      rankedEntriesForCounts ({ counts: titleTermCounts, total, cap: titleTermCap })
	  .filter (([term]) => String (term).length <= 24)
	  .forEach (([term]) => {
	    addQuestion (buildIndexedQuestion ({
		condition: { type: 'title-contains', text: String (term) },
		text: `タイトルに「${ term }」が含まれる?`,
		kind: 'title',
		priorityWeight: .99,
		materialIndex }))
	  })

      if (titleLengthMedian > 0)
	addQuestion (buildIndexedQuestion ({
	    condition: {
		type: 'title-length-at-least',
		length: titleLengthMedian },
	    text: `タイトルは ${ titleLengthMedian } 文字以上?`,
	    kind: 'title',
	    priorityWeight: .72,
	    materialIndex }))

      if (asciiCount > 0 && asciiCount < total)
	addQuestion (buildIndexedQuestion ({
	    condition: { type: 'title-has-ascii' },
	    text: 'タイトルに英数字が混じっている?',
	    kind: 'title',
	    priorityWeight: .68,
	    materialIndex }))
    }
  else if (confirmationPost)
    {
      const targetPostId = confirmationPost.id
      const host = materialIndex.hostByPostId.get (targetPostId)
      const year = materialIndex.originalYearByPostId.get (targetPostId)
      const monthDay = materialIndex.originalMonthDayByPostId.get (targetPostId)
      const titleTerms = materialIndex.titleTermsByPostId.get (targetPostId) ?? []
      const titleLength = materialIndex.titleLengthByPostId.get (targetPostId) ?? 0
      const tagKeys = materialIndex.tagKeysByPostId.get (targetPostId) ?? []

      if (host)
	addQuestion (buildIndexedQuestion ({
	    condition: { type: 'source', host },
	    text: `${ host } の投稿を思い浮かべている?`,
	    kind: 'source',
	    priorityWeight: 1.02,
	    materialIndex }))
      if (year != null)
	addQuestion (buildDateQuestion ({
	    type: 'original-year',
	    year }))
      if (monthDay && specialOriginalMonthDayLabelFor (monthDay))
	addQuestion (buildDateQuestion ({
	    type: 'original-month-day',
	    monthDay }))

      tagKeys
	  .slice (0, 20)
	  .forEach (key => {
	    addQuestion (buildIndexedQuestion ({
		condition: { type: 'tag', key },
		text: indexedQuestionTextForTag (key),
		kind: 'tag',
		priorityWeight: 1.08,
		materialIndex }))
	  })

      titleTerms
	  .filter (term => term.length <= 24)
	  .slice (0, 8)
	  .forEach (term => {
	    addQuestion (buildIndexedQuestion ({
		condition: { type: 'title-contains', text: term },
		text: `タイトルに「${ term }」が含まれる?`,
		kind: 'title',
		priorityWeight: .99,
		materialIndex }))
	  })

      if (titleLength > 0)
	addQuestion (buildIndexedQuestion ({
	    condition: {
		type: 'title-length-at-least',
		length: titleLength },
	    text: `タイトルは ${ titleLength } 文字以上?`,
	    kind: 'title',
	    priorityWeight: .72,
	    materialIndex }))

      if (materialIndex.titleAsciiPostIds.has (targetPostId))
	addQuestion (buildIndexedQuestion ({
	    condition: { type: 'title-has-ascii' },
	    text: 'タイトルに英数字が混じっている?',
	    kind: 'title',
	    priorityWeight: .68,
	    materialIndex }))
    }

  return mergeQuestions ([...questions, ...acceptedQuestions])
}

const candidatePostsForState = ({
    posts,
    questionById,
    materialIndex,
    matchIndex,
    answers,
    softenedQuestionIds,
    rejectedPostIds,
    recoveredCandidatePosts,
    scores,
}: {
    posts:                   Post[]
    questionById:            Map<string, GekanatorQuestion>
    materialIndex:           GekanatorQuestionMaterialIndex
    matchIndex:              GekanatorMatchIndex
    answers:                 GekanatorAnswerLog[]
    softenedQuestionIds:     Set<string>
    rejectedPostIds:         Set<number>
    recoveredCandidatePosts: Map<number, RecoveredCandidateState>
    scores:                  Map<number, number>
}): Post[] => {
  const dynamicMatchIndex = new Map<string, Set<number>> ()
  const answerAllowsHardFilter = (answer: GekanatorAnswerValue): boolean =>
      answer === 'yes' || answer === 'no'

  return posts.filter (post => {
    if (rejectedPostIds.has (post.id))
      return false

    const recoveredCandidate = recoveredCandidatePosts.get (post.id)

    const survivesHardFiltering = answers.every ((answer, index) => {
      if (recoveredCandidate != null && index < recoveredCandidate.answerCountAtRecovery)
	return true
      if (softenedQuestionIds.has (answer.questionId))
	return true
      if (!(answerAllowsHardFilter (answer.answer)))
	return true

      const question = questionById.get (answer.questionId)
      const condition = answer.questionCondition ?? question?.condition
      if (!(condition))
	return true

      const matched = (() => {
	if (question)
	  return positiveMatchingPostIdsForQuestion ({
	      posts,
	      materialIndex,
	      matchIndex,
	      question,
	      dynamicMatchIndex })

	return matchingPostIdsForCondition ({
	    condition,
	    materialIndex })
      }) ()
      const useExpectedAnswer =
	  question != null
	  && usesLearnedTagExamples (question)
      if (question && !(questionSupportsAnswerBasedHardFiltering (question)))
	return true

      if (matched != null)
	{
	  if (useExpectedAnswer)
	    {
	      const expected = expectedAnswerForQuestion (question, post)
	      return expected == null
		  || expected === 'unknown'
		  || expected === answer.answer
	    }

	  return answer.answer === 'yes' ? matched.has (post.id) : !(matched.has (post.id))
	}

      if (!(question))
	return true

      const expected = expectedAnswerForQuestion (question, post)
      return expected == null || expected === 'unknown' || expected === answer.answer
    })
    if (!(survivesHardFiltering))
      return false

    return postPassesScoreDrop ({
	postId: post.id,
	scores,
	recoveredCandidatePosts })
  })
}

const hasDiscriminatingHardSplitForQuestion = ({
    candidateIds,
    question,
    posts,
    materialIndex,
    matchIndex,
}: {
    candidateIds:   number[]
    question:       GekanatorQuestion | null
    posts:          Post[]
    materialIndex:  GekanatorQuestionMaterialIndex
    matchIndex:     GekanatorMatchIndex
}): boolean => {
  if (!(question))
    return false

  if (isUserSuggestedLearnedSemanticQuestion (question))
    return learnedSemanticQuestionIsEffectiveForCandidateIds ({
	candidateIds,
	posts,
	question })

  const dynamicMatchIndex = new Map<string, Set<number>> ()
  const yesCount = matchingPostCountInIds ({
      candidateIds,
      posts,
      materialIndex,
      matchIndex,
      question,
      dynamicMatchIndex })
  const noCount = candidateIds.length - yesCount

  return yesCount > 0 && noCount > 0
}


const recalculateScores = ({
    posts,
    questions,
    answers,
    softenedQuestionIds,
    materialIndex,
    matchIndex,
}: {
    posts:                Post[]
    questions:            GekanatorQuestion[]
    answers:              GekanatorAnswerLog[]
    softenedQuestionIds:  Set<string>
    materialIndex:        GekanatorQuestionMaterialIndex
    matchIndex:           GekanatorMatchIndex
}): Map<number, number> => {
  const questionById = new Map (questions.map (question => [question.id, question]))
  const nextScores = new Map<number, number> ()
  const dynamicMatchIndex = new Map<string, Set<number>> ()

  answers.forEach (answer => {
    const question = questionById.get (answer.questionId)
    if (!(question))
      return

    const weight = scoreWeightForAnswer (answer, softenedQuestionIds)
    applyQuestionAnswerDeltaToScores ({
	posts,
	question,
	answer: answer.answer,
	weight,
	nextScores,
	materialIndex,
	matchIndex,
	dynamicMatchIndex })
  })

  return nextScores
}


const confidencesFor = (posts: Post[], scores: Map<number, number>): Confidence[] => {
  if (posts.length === 0)
    return []

  const raw = posts.map (post => ({ post, score: scores.get (post.id) ?? 0 }))
  const maxScore = Math.max (...raw.map (({ score }) => score))
  const weighted = raw.map (item => ({
      ...item,
      weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
  const total = weighted.reduce ((sum, item) => sum + item.weight, 0) || 1

  return weighted
      .map (({ post, score, weight }) => ({
	  post,
	  score,
	  percent: weight / total * 100 }))
      .sort ((a, b) => b.percent - a.percent)
}


const entropyFor = (confidences: Confidence[]): number =>
    confidences.reduce ((sum, item) => {
      const p = item.percent / 100
      return p > 0 ? sum - p * Math.log2 (p) : sum
    }, 0)


const effectiveCandidatesFor = (confidences: Confidence[]): number => {
  const concentration = confidences.reduce ((sum, item) => {
    const p = item.percent / 100
    return sum + p * p
  }, 0)

  return concentration > 0 ? 1 / concentration : 0
}


const previewAnswer = ({
    posts,
    scores,
    question,
    answer,
    materialIndex,
    matchIndex,
}: {
    posts:     Post[]
    scores:    Map<number, number>
    question:  GekanatorQuestion
    answer:    GekanatorAnswerValue
    materialIndex: GekanatorQuestionMaterialIndex
    matchIndex: GekanatorMatchIndex
}): AnswerPreview => {
  const postById = new Map (posts.map (post => [post.id, post]))
  const dynamicMatchIndex = new Map<string, Set<number>> ()
  const nextPostIds = postIdsForHardAnswer ({
      candidateIds: posts.map (post => post.id),
      question,
      answer,
      posts,
      materialIndex,
      matchIndex,
      dynamicMatchIndex })
  const nextPosts = nextPostIds
      .map (postId => postById.get (postId))
      .filter ((post): post is Post => post != null)
  if (nextPosts.length === 0)
    return {
	answer,
	top: null,
	candidateCount: 0,
	effectiveCandidates: 0,
	entropy: 0 }

  const nextScores = new Map (scores)
  applyQuestionAnswerDeltaToScores ({
      posts,
      question,
      answer,
      weight: 1,
      nextScores,
      materialIndex,
      matchIndex,
      dynamicMatchIndex })

  const confidences = confidencesFor (nextPosts, nextScores)

  return {
      answer,
      top: confidences[0] ?? null,
      candidateCount: nextPosts.length,
      effectiveCandidates: effectiveCandidatesFor (confidences),
      entropy: entropyFor (confidences) }
}


const mergeQuestions = (questions: GekanatorQuestion[]): GekanatorQuestion[] => {
  const byId = new Map<string, GekanatorQuestion> ()
  questions.forEach (question => {
    const current = byId.get (question.id)
    if (shouldReplaceMergedQuestion (current, question))
      byId.set (question.id, question)
  })
  return [...byId.values ()]
}


const softenNextQuestionIds = ({
    questions,
    answers,
    softenedQuestionIds,
}: {
    questions:            GekanatorQuestion[]
    answers:              GekanatorAnswerLog[]
    softenedQuestionIds:  Set<string>
}): Set<string> | null => {
  const questionById = new Map (questions.map (question => [question.id, question]))
  const candidate = [...answers]
      .reverse ()
      .map (answer => {
	const question = questionById.get (answer.questionId)
	return { answer, question }
      })
      .filter ((item): item is {
	  answer:    GekanatorAnswerLog
	  question:  GekanatorQuestion } =>
	      item.question != null
	      && item.answer.answer !== 'unknown'
	      && !(softenedQuestionIds.has (item.answer.questionId)))
      .sort ((a, b) => questionDifficulty (b.question) - questionDifficulty (a.question))[0]

  if (!(candidate))
    return null

  return new Set ([...softenedQuestionIds, candidate.answer.questionId])
}


type ExclusiveConditionGroup =
    | 'original-month'
    | 'original-year'
    | 'original-month-day'
    | 'source'


const exclusiveConditionGroupFor = (
    condition:  GekanatorQuestion['condition']): ExclusiveConditionGroup | null => {
  switch (condition.type)
  {
  case 'original-month':
    return 'original-month'
  case 'original-year':
    return 'original-year'
  case 'original-month-day':
    return 'original-month-day'
  case 'source':
    return 'source'
  default:
    return null
  }
}


const sameConditionValue = (
    left:   GekanatorQuestion['condition'],
    right:  GekanatorQuestion['condition']): boolean => {
  const leftTitleLength = titleLengthMinimumForCondition (left)
  const rightTitleLength = titleLengthMinimumForCondition (right)
  if (leftTitleLength != null || rightTitleLength != null)
    return leftTitleLength != null
	&& rightTitleLength != null
	&& leftTitleLength === rightTitleLength

  if (left.type !== right.type)
    return false

  const valueKeyFor = (condition: GekanatorQuestion['condition']): string => {
    switch (condition.type)
    {
    case 'tag':
      return condition.key
    case 'source':
      return condition.host
    case 'original-year':
      return String (condition.year)
    case 'original-month':
      return String (condition.month)
    case 'original-month-day':
      return condition.monthDay
    case 'title-has-ascii':
      return ''
    case 'title-contains':
      return condition.text
    case 'post-similarity':
      return `${ condition.postId }:${ condition.answer }:${ condition.threshold }`
    case 'title-length-at-least':
    case 'title-length-greater-than':
      return String (titleLengthMinimumForCondition (condition) ?? '')
    }
  }

  return valueKeyFor (left) === valueKeyFor (right)
}


const isMonthCrossMatch = (
    candidate:  GekanatorQuestion['condition'],
    previous:   GekanatorQuestion['condition']): boolean => {
  const candidateMonth = monthForCondition (candidate)
  const previousMonth = monthForCondition (previous)
  if (candidateMonth == null || previousMonth == null)
    return false

  const sameType = candidate.type === previous.type
  if (sameType)
    return false

  return candidateMonth === previousMonth
}


const isExclusiveContradiction = (
    candidate:  GekanatorQuestion['condition'],
    previous:   GekanatorQuestion['condition']): boolean => {
  const candidateGroup = exclusiveConditionGroupFor (candidate)
  const previousGroup = exclusiveConditionGroupFor (previous)

  if (candidateGroup != null && candidateGroup === previousGroup)
    return !(sameConditionValue (candidate, previous))

  const candidateMonth = monthForCondition (candidate)
  const previousMonth = monthForCondition (previous)
  if (candidateMonth != null && previousMonth != null)
    return candidateMonth !== previousMonth

  return false
}


const contradictionPenaltyFor = ({
    question,
    answers,
}: {
    question:  GekanatorQuestion
    answers:   GekanatorAnswerLog[]
}): number => {
  return answers.reduce ((sum, answer) => {
    const previous = answer.questionCondition
    if (!(previous))
      return sum

    switch (answer.answer)
    {
    case 'yes':
      return sum + (isExclusiveContradiction (question.condition, previous) ? 100 : 0)
    case 'partial':
      return sum + (isExclusiveContradiction (question.condition, previous) ? 25 : 0)
    case 'no':
      if (
	  sameConditionValue (question.condition, previous)
	  || isMonthCrossMatch (question.condition, previous))
	return sum + 40

      return sum
    case 'probably_no':
      if (
	  sameConditionValue (question.condition, previous)
	  || isMonthCrossMatch (question.condition, previous))
	return sum + 20

      return sum
    default:
      return sum
    }
  }, 0)
}


const chooseQuestion = (
    { posts,
      questions,
      scores,
      answers,
      askedIds,
      gameSeed,
      recentFirstQuestionPenaltyById,
      userPriorWeights,
      materialIndex,
      matchIndex }: { posts:      Post[]
		      questions:  GekanatorQuestion[]
		      scores:     Map<number, number>
		      answers:    GekanatorAnswerLog[]
		      askedIds:   Set<string>
		      gameSeed:   string
		      recentFirstQuestionPenaltyById: Map<string, number>
		      userPriorWeights: Map<number, number>
		      materialIndex: GekanatorQuestionMaterialIndex
		      matchIndex: GekanatorMatchIndex }): QuestionSelection | null => {
  const dynamicMatchIndex = new Map<string, Set<number>> ()

  const invertedSignature = (signature: string): string =>
      signature.replace (/[01]/g, value => value === '1' ? '0' : '1')

  const redundantSignatures = (candidates:  Post[]): Set<string> => {
    const signatures = new Set<string> ()
    questions
	.filter (question => askedIds.has (question.id))
	.forEach (question => {
	  const signature = signatureForCandidateIds ({
	      candidateIds: candidates.map (post => post.id),
	      posts,
	      materialIndex,
	      matchIndex,
	      question,
	      dynamicMatchIndex })
	  signatures.add (signature)
	  signatures.add (invertedSignature (signature))
	})

    return signatures
  }

  const scoredPosts = posts
      .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
      .sort ((a, b) => b.score - a.score)
  const maxScore = scoredPosts[0]?.score ?? 0
  const weightedPosts = scoredPosts.map (item => ({
      ...item,
      weight: Math.exp ((item.score - maxScore) / confidenceTemperature) }))
  const totalWeight =
      weightedPosts.reduce ((sum, item) => sum + item.weight, 0) || 1
  const normalisedWeightedPosts =
      weightedPosts.map (item => ({ ...item, weight: item.weight / totalWeight }))
  const weightedEntropy = distributionEntropy (
      normalisedWeightedPosts.map (item => item.weight))
  const learningTargetPosts = learningTargetPostsForCandidates ({
      scoredPosts,
      gameSeed })

  const rank = (
      questionsToRank:    GekanatorQuestion[],
      candidates:         { post: Post; score: number }[],
      weightedCandidates: { post: Post; score: number; weight: number }[]) => {
    const redundant = redundantSignatures (candidates.map (item => item.post))
    const candidateById = new Map (candidates.map (item => [item.post.id, item.post]))
    const candidateIds = candidates.map (item => item.post.id)
    const candidateIdSet = new Set (candidateIds)
    const priorEntries = [...userPriorWeights.entries ()]
	.filter (([postId]) => candidateById.has (postId))
    const priorWeightTotal =
	priorEntries.reduce ((sum, [, weight]) => sum + weight, 0)
    const nonTagCount =
	questions.filter (question => askedIds.has (question.id) && question.kind !== 'tag').length

    return questionsToRank
      .map (question => {
	if (isQuestionHardFilteredAfterAnswers (question, answers))
	  return null

	const signature = signatureForCandidateIds ({
	    candidateIds,
	    posts,
	    materialIndex,
	    matchIndex,
	    question,
	    dynamicMatchIndex })
	if (redundant.has (signature))
	  return null

	if (isUserSuggestedLearnedSemanticQuestion (question))
	  {
	    const stats = learnedSemanticStatsForCandidateIds ({
		candidateIds,
		posts,
		question })
	    const effective =
		learnedSemanticQuestionIsEffectiveForCandidateIds ({
		    candidateIds,
		    posts,
		    question })
	    const learningValue = learnedSemanticLearningValueForTopPosts ({
		question,
		learningTargetPosts,
		candidateIds,
		posts })
	    if (!(effective) && !(learningValue.hasLearningValue))
	      return null

	    const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
	    const humanOffset = humanPriorityOffsetFor (question)
	    const sourceBonus = sourcePriorityOffset (question)
	    const priorityBonus = priorityWeightOffset (question)
	    const repeatPenalty = (() => {
	      if (answers.length === 0)
		return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5

	      return 0
	    }) ()
	    const categoryPenalty = questionCategoryPenalty (
		question,
		answers.length,
		repeatPenalty)
	    if (!(effective))
	      {
		return {
		    question,
		    score: (
			-(learningValue.missingTopCount * 20)
			+ learningValue.knownCount * .5
			+ contradictionPenalty
			+ humanOffset
			+ sourceBonus
			+ priorityBonus
			+ categoryPenalty),
		    narrow: false,
		    effectiveUserSuggested: false,
		    learningUserSuggested: true }
	      }

	    const positiveWeight = weightedCandidates.reduce ((sum, item) =>
		sum + (
		  stats.positiveIds.has (item.post.id)
		  ? item.weight
		  : 0),
	    0)
	    const negativeWeight = weightedCandidates.reduce ((sum, item) =>
		sum + (
		  stats.negativeIds.has (item.post.id)
		  ? item.weight
		  : 0),
	    0)
	    const unknownWeight = Math.max (0, 1 - positiveWeight - negativeWeight)
	    if (positiveWeight <= 0 || negativeWeight <= 0)
	      return null

	    const positivePosteriorWeights = weightedCandidates
		.filter (item => stats.positiveIds.has (item.post.id))
		.map (item => item.weight / positiveWeight)
	    const negativePosteriorWeights = weightedCandidates
		.filter (item => stats.negativeIds.has (item.post.id))
		.map (item => item.weight / negativeWeight)
	    const unknownPosteriorWeights =
		unknownWeight > 0
		? weightedCandidates
		      .filter (item => stats.unknownIds.has (item.post.id))
		      .map (item => item.weight / unknownWeight)
		: []
	    const infoGain =
		weightedEntropy
		- (
		    positiveWeight * distributionEntropy (positivePosteriorWeights)
		    + negativeWeight * distributionEntropy (negativePosteriorWeights)
		    + unknownWeight * distributionEntropy (unknownPosteriorWeights))
	    if (infoGain < (candidates.length >= 10 ? .02 : .008))
	      return null

	    const weightedSplitScore = Math.abs (.5 - positiveWeight)
	    const unweightedSplitScore =
		Math.abs (candidates.length / 2 - stats.positiveCount) / candidates.length
	    const narrowPenalty =
		learnedSemanticNarrowPenaltyForStats (candidates.length, stats)
	    const infoGainBonus = -Math.min (1.2, infoGain) * 4

	    return {
		question,
		score: weightedSplitScore * 100
		    + unweightedSplitScore * 8
		    + narrowPenalty
		    + contradictionPenalty
		    + humanOffset
		    + sourceBonus
		    + priorityBonus
		    + categoryPenalty
		    + infoGainBonus,
		narrow: narrowPenalty > 0,
		effectiveUserSuggested: true,
		learningUserSuggested: false }
	  }

	const yes = matchingPostCountInIds ({
	    candidateIds,
	    candidateIdSet,
	    posts,
	    materialIndex,
	    matchIndex,
	    question,
	    dynamicMatchIndex })
	const no = candidates.length - yes
	if (yes === 0 || no === 0)
	  return null

	const yesWeight = matchingWeightInCandidates ({
	    candidates: weightedCandidates.map (item => ({
		post: item.post,
		weight: item.weight })),
	    posts,
	    materialIndex,
	    matchIndex,
	    question,
	    dynamicMatchIndex })
	const noWeight = 1 - yesWeight
	if (yesWeight <= 0 || noWeight <= 0)
	  return null
	if (Math.min (yesWeight, noWeight) < .08)
	  return null

	const matched = positiveMatchingPostIdsForQuestion ({
	    posts,
	    materialIndex,
	    matchIndex,
	    question,
	    dynamicMatchIndex })
	const yesPosteriorWeights = weightedCandidates
	    .filter (item => matched.has (item.post.id))
	    .map (item => item.weight / yesWeight)
	const noPosteriorWeights = weightedCandidates
	    .filter (item => !(matched.has (item.post.id)))
	    .map (item => item.weight / noWeight)
	const infoGain =
	    weightedEntropy
	    - (
		yesWeight * distributionEntropy (yesPosteriorWeights)
		+ noWeight * distributionEntropy (noPosteriorWeights))
	if (infoGain < (candidates.length >= 10 ? .02 : .008))
	  return null

	const weightedSplitScore = Math.abs (.5 - yesWeight)
	const unweightedSplitScore = Math.abs (candidates.length / 2 - yes) / candidates.length
	const tagPenalty = question.kind === 'tag' && nonTagCount < 4 ? .12 : 0
	const minSide = candidates.length < 10 ? 1 : Math.max (3, candidates.length * .08)
	const narrowPenalty = yes < minSide || no < minSide ? .15 : 0
	const contradictionPenalty = contradictionPenaltyFor ({ question, answers })
	const humanOffset = humanPriorityOffsetFor (question)
	const sourceBonus = sourcePriorityOffset (question)
	const priorityBonus = priorityWeightOffset (question)
	const repeatPenalty = (() => {
	  if (answers.length === 0)
	    return (recentFirstQuestionPenaltyById.get (question.id) ?? 0) * 4.5

	  return 0
	}) ()
	const categoryPenalty = questionCategoryPenalty (
	    question,
	    answers.length,
	    repeatPenalty)
	const priorSplitScore = (() => {
	  if (priorWeightTotal <= 0)
	    return null

	  return Math.abs (
	      .5 - (
		priorEntries.reduce (
		    (sum, [postId, weight]) => {
		      return sum + (matched.has (postId) ? weight : 0)
		    },
		    0) / priorWeightTotal))
	}) ()
	const priorBonus = (() => {
	  if (priorSplitScore == null)
	    return 0

	  return Math.max (0, .22 - priorSplitScore) * -18
	}) ()
	const infoGainBonus = -Math.min (1.2, infoGain) * 4

	return { question,
		 score: weightedSplitScore * 100
		     + unweightedSplitScore * 8
		     + tagPenalty
		     + narrowPenalty
		     + contradictionPenalty
		     + humanOffset
		     + sourceBonus
		     + priorityBonus
		     + categoryPenalty
		     + priorBonus
		     + infoGainBonus,
		 narrow: narrowPenalty > 0,
		 effectiveUserSuggested: false,
		 learningUserSuggested: false }
      })
      .filter ((item): item is {
	  question:  GekanatorQuestion
	  score:     number
	  narrow:    boolean
	  effectiveUserSuggested: boolean
	  learningUserSuggested: boolean } => item != null && Number.isFinite (item.score))
      .sort ((a, b) => a.score - b.score)
  }

  const unansweredQuestions =
      questions.filter (question => !(askedIds.has (question.id)))
  const ranked = rank (unansweredQuestions, scoredPosts, normalisedWeightedPosts)
  const generalRankedPool =
      ranked.some (item => !(item.narrow)) ? ranked.filter (item => !(item.narrow)) : ranked
  const effectiveUserSuggestedPool =
      ranked
      .filter (item => item.effectiveUserSuggested)
      .slice (0, 16)
  const learningUserSuggestedPool =
      ranked
      .filter (item => item.learningUserSuggested)
      .slice (0, 16)
  const normalPool =
      generalRankedPool
      .filter (item => !(item.effectiveUserSuggested) && !(item.learningUserSuggested))
      .slice (0, 16)
  const purposeCounts = questionPurposeCountsFor (answers)
  const totalNormalPhaseQuestionCount = purposeCounts.totalNormalPhaseQuestionCount
  const effectiveRatio =
      totalNormalPhaseQuestionCount > 0
      ? purposeCounts.effectiveUserSuggestedCount / totalNormalPhaseQuestionCount
      : 0
  const learningRatio =
      totalNormalPhaseQuestionCount > 0
      ? purposeCounts.learningUserSuggestedCount / totalNormalPhaseQuestionCount
      : 0
  const totalUserSuggestedRatio =
      totalNormalPhaseQuestionCount > 0
      ? purposeCounts.totalUserSuggestedCount / totalNormalPhaseQuestionCount
      : 0
  let selectedPool = normalPool
  let selectedPurpose: GekanatorQuestionPurpose = 'normal'

  if (
	effectiveRatio < targetEffectiveUserSuggestedQuestionRatio
	&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
	&& effectiveUserSuggestedPool.length > 0)
    {
      selectedPool = effectiveUserSuggestedPool
      selectedPurpose = 'effective_user_suggested'
    }
  else if (
	learningRatio < targetLearningUserSuggestedQuestionRatio
	&& totalUserSuggestedRatio < targetTotalUserSuggestedQuestionRatio
	&& learningUserSuggestedPool.length > 0)
    {
      selectedPool = learningUserSuggestedPool
      selectedPurpose = 'learning_user_suggested'
    }
  else if (normalPool.length > 0)
    {
      selectedPool = normalPool
      selectedPurpose = 'normal'
    }
  else if (effectiveUserSuggestedPool.length > 0)
    {
      selectedPool = effectiveUserSuggestedPool
      selectedPurpose = 'effective_user_suggested'
    }
  else if (learningUserSuggestedPool.length > 0)
    {
      selectedPool = learningUserSuggestedPool
      selectedPurpose = 'learning_user_suggested'
    }

  if (selectedPool.length === 0)
    return null

  const bestScore = selectedPool[0]?.score ?? 0
  const weightedPool = selectedPool.map (item => ({
      ...item,
      weight: Math.exp ((bestScore - item.score) / (answers.length === 0 ? 2.8 : 2.1)) }))
  const totalPoolWeight =
      weightedPool.reduce ((sum, item) => sum + item.weight, 0) || 1
  const seed = `${ gameSeed }:${ [...askedIds].sort ().join ('|') }:${
      weightedPool.map (item => `${ item.question.id }:${ item.score.toFixed (4) }`).join ('|')
  }`
  const target = deterministicUnitFloat (seed) * totalPoolWeight
  let cumulative = 0

  for (const item of weightedPool)
    {
	cumulative += item.weight
	if (target <= cumulative)
	  return {
	      question: item.question,
	      questionPurpose: selectedPurpose,
	      effectiveQuestion: selectedPurpose === 'effective_user_suggested',
	      learningQuestion: selectedPurpose === 'learning_user_suggested' }
    }

  const selectedQuestion = weightedPool[weightedPool.length - 1]?.question
  if (selectedQuestion == null)
    return null

  return {
      question: selectedQuestion,
      questionPurpose: selectedPurpose,
      effectiveQuestion: selectedPurpose === 'effective_user_suggested',
      learningQuestion: selectedPurpose === 'learning_user_suggested' }
}


const winningRunPriorityFor = (
    expected:    GekanatorAnswerValue): number | null => {
  if (expected === 'yes')
    return 0
  if (expected === 'partial')
    return 1
  if (expected === 'no' || expected === 'probably_no')
    return 2

  return null
}


const chooseWinningRunQuestion = ({
    posts,
    targetPost,
    answers,
    askedIds,
    acceptedQuestions,
    materialIndex,
    matchIndex,
}: {
    posts:      Post[]
    targetPost: Post
    answers:    GekanatorAnswerLog[]
    askedIds:   Set<string>
    acceptedQuestions: GekanatorQuestion[]
    materialIndex: GekanatorQuestionMaterialIndex
    matchIndex: GekanatorMatchIndex
}): GekanatorQuestion | null => {
  const dynamicMatchIndex = new Map<string, Set<number>> ()
  const ranked = buildQuestionsForCandidateIds ({
      candidateIds: posts.map (post => post.id),
      materialIndex,
      acceptedQuestions,
      mode: 'confirmation',
      confirmationPostId: targetPost.id})
      .filter (question => {
	if (askedIds.has (question.id))
	  return false
	if (isQuestionHardFilteredAfterAnswers (question, answers))
	  return false

	const expected = expectedAnswerForQuestion (question, targetPost)
	return expected != null && expected !== 'unknown'
      })
      .map (question => {
	const expected = expectedAnswerForQuestion (question, targetPost)
	const priority = expected == null ? null : winningRunPriorityFor (expected)
	if (priority == null)
	  return null

	let matchingCount = 0
	if (isUserSuggestedLearnedSemanticQuestion (question))
	  {
	    const stats = learnedSemanticStatsForCandidateIds ({
		candidateIds: posts.map (post => post.id),
		posts,
		question })
	    matchingCount =
		expected === 'yes' || expected === 'partial'
		? stats.positiveCount
		: stats.negativeCount
	  }
	else
	  {
	    const yesCount = matchingPostCountInIds ({
		candidateIds: posts.map (post => post.id),
		posts,
		materialIndex,
		matchIndex,
		question,
		dynamicMatchIndex })
	    matchingCount =
		expected === 'yes' || expected === 'partial' ? yesCount : posts.length - yesCount
	  }

	return {
	    question,
	    priority,
	    humanOffset: humanPriorityOffsetFor (question),
	    matchingCount }
      })
      .filter ((item): item is {
	  question:      GekanatorQuestion
	  priority:      number
	  humanOffset:   number
	  matchingCount: number } => item != null)
      .sort ((a, b) => {
	if (a.priority !== b.priority)
	  return a.priority - b.priority

	if (a.humanOffset !== b.humanOffset)
	  return a.humanOffset - b.humanOffset

	if (a.question.priorityWeight !== b.question.priorityWeight)
	  return b.question.priorityWeight - a.question.priorityWeight

	if (a.matchingCount !== b.matchingCount)
	  return a.matchingCount - b.matchingCount

	return a.question.id.localeCompare (b.question.id)
      })

  if (ranked.length > 0)
    return ranked[0]?.question ?? null

  return null
}


const chooseFallbackQuestion = ({
    posts,
    allPosts,
    questions,
    answers,
    askedIds,
    scores,
    materialIndex,
    matchIndex,
}: {
    posts:      Post[]
    allPosts:   Post[]
    questions:  GekanatorQuestion[]
    answers:    GekanatorAnswerLog[]
    askedIds:   Set<string>
    scores:     Map<number, number>
    materialIndex: GekanatorQuestionMaterialIndex
    matchIndex: GekanatorMatchIndex
}): GekanatorQuestion | null => {
  if (posts.length === 0)
    return null

  const candidateIds = posts.map (post => post.id)
  const fallbackPosts = posts
      .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
      .sort ((a, b) => b.score - a.score)
      .slice (0, Math.min (6, posts.length))
      .map (item => item.post)
  const fallbackQuestions = mergeQuestions (
      fallbackPosts.flatMap (post => buildQuestionsForCandidateIds ({
	  candidateIds,
	  materialIndex,
	  acceptedQuestions: [],
	  mode: 'confirmation',
	  confirmationPostId: post.id})))
      .slice (0, 32)
  const dynamicMatchIndex = new Map<string, Set<number>> ()
  const ranked = mergeQuestions ([
      ...questions,
      ...fallbackQuestions])
      .filter (question =>
	  question.source !== 'user_suggested'
	  &&
	  !(askedIds.has (question.id))
	  && !(isQuestionHardFilteredAfterAnswers (question, answers)))
      .map (question => {
	if (isUserSuggestedLearnedSemanticQuestion (question))
	  {
	    if (!(learnedSemanticQuestionIsEffectiveForCandidateIds ({
		candidateIds,
		posts: allPosts,
		question })))
	      return null

	    const stats = learnedSemanticStatsForCandidateIds ({
		candidateIds,
		posts: allPosts,
		question })

	    return {
		question,
		knownCount: stats.knownCount,
		balance: Math.abs (stats.positiveCount - stats.negativeCount),
		humanOffset: humanPriorityOffsetFor (question) }
	  }

	const yesCount = matchingPostCountInIds ({
	    candidateIds,
	    posts: allPosts,
	    materialIndex,
	    matchIndex,
	    question,
	    dynamicMatchIndex })
	const noCount = candidateIds.length - yesCount
	if (yesCount === 0 || noCount === 0)
	  return null

	return {
	    question,
	    knownCount: candidateIds.length,
	    balance: Math.abs (yesCount - noCount),
	    humanOffset: humanPriorityOffsetFor (question) }
      })
      .filter ((item): item is {
	  question:   GekanatorQuestion
	  knownCount: number
	  balance:    number
	  humanOffset: number } => item != null)
      .sort ((a, b) => {
	if (a.humanOffset !== b.humanOffset)
	  return a.humanOffset - b.humanOffset

	if (a.balance !== b.balance)
	  return a.balance - b.balance

	if (a.knownCount !== b.knownCount)
	  return b.knownCount - a.knownCount

	if (a.question.priorityWeight !== b.question.priorityWeight)
	  return b.question.priorityWeight - a.question.priorityWeight

	return a.question.id.localeCompare (b.question.id)
      })

  return ranked[0]?.question ?? null
}


const shouldEnterGuessPhase = (
    reason: GuessReason | null): reason is 'hard_max_questions' | 'winning_run_finished' | 'question_count_checkpoint' =>
    (reason === 'hard_max_questions'
     || reason === 'winning_run_finished'
     || reason === 'question_count_checkpoint')


const isWinningRunActive = (
    winningRunTargetId:         number | null,
    winningRunStartAnswerCount: number | null): boolean =>
    winningRunTargetId != null && winningRunStartAnswerCount != null


const winningRunQuestionCount = (
    answers:                    GekanatorAnswerLog[],
    winningRunStartAnswerCount: number | null): number => {
  if (winningRunStartAnswerCount == null)
    return 0

  return answers
      .slice (winningRunStartAnswerCount)
      .filter (answer => answer.questionMode === 'winning_run')
      .length
}


const nextQuestionPlanFor = (
    { posts,
      eligiblePosts,
      availablePosts,
      acceptedQuestions,
      scores,
      answers,
      askedIds,
      gameSeed,
      recentFirstQuestionPenaltyById,
      userPriorWeights,
      materialIndex,
      matchIndex,
      lastGuessQuestionCount,
      winningRunTargetId,
      winningRunStartAnswerCount }: { posts:      Post[]
				      eligiblePosts: Post[]
    				      availablePosts: Post[]
    				      acceptedQuestions: GekanatorQuestion[]
    				      scores:     Map<number, number>
    				      answers:    GekanatorAnswerLog[]
    				      askedIds:   Set<string>
    				      gameSeed:   string
    				      recentFirstQuestionPenaltyById: Map<string, number>
    				      userPriorWeights: Map<number, number>
    				      materialIndex: GekanatorQuestionMaterialIndex
    				      matchIndex: GekanatorMatchIndex
    				      lastGuessQuestionCount: number
    				      winningRunTargetId: number | null
    				      winningRunStartAnswerCount: number | null }): { question: GekanatorQuestion | null
     guess: Post | null
     guessReason: GuessReason | null
     questionMode: QuestionMode
     questionPurpose?: GekanatorQuestionPurpose
     effectiveQuestion?: boolean
     learningQuestion?: boolean
     winningRunTargetId: number | null
     winningRunStartAnswerCount: number | null } => {
  const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts

  const checkpointGuess =
      answers.length > 0
      && answers.length - lastGuessQuestionCount >= minQuestionsBeforeCertainGuess

  if (answers.length >= hardMaxQuestions)
    {
      return {
	  question: null,
	  guess: bestPost (guessablePosts, scores),
	  guessReason: 'hard_max_questions',
	  questionMode: null,
	  questionPurpose: undefined,
	  effectiveQuestion: false,
	  learningQuestion: false,
	  winningRunTargetId,
	  winningRunStartAnswerCount }
    }

  if (checkpointGuess)
    {
      return {
	  question: null,
	  guess: bestPost (guessablePosts, scores),
	  guessReason: 'question_count_checkpoint',
	  questionMode: null,
	  questionPurpose: undefined,
	  effectiveQuestion: false,
	  learningQuestion: false,
	  winningRunTargetId,
	  winningRunStartAnswerCount }
    }

  const nextWinningRunTargetId = eligiblePosts.length === 1 ? eligiblePosts[0]?.id ?? null : null
  const nextWinningRunStartAnswerCount = (() => {
    if (nextWinningRunTargetId == null)
      return null
    if (
	isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
	&& winningRunTargetId === nextWinningRunTargetId
	&& winningRunStartAnswerCount != null)
      return winningRunStartAnswerCount

    return answers.length
  }) ()
  const nextWinningRunTargetPost = (() => {
    if (nextWinningRunTargetId == null)
      return null

    return posts.find (post => post.id === nextWinningRunTargetId) ?? null
  }) ()
  const buildQuestionsForPosts = (scopePosts: Post[]): GekanatorQuestion[] =>
      buildQuestionsForCandidateIds ({
	  candidateIds: scopePosts.map (post => post.id),
	  materialIndex,
	  acceptedQuestions,
	  mode: 'split' })

  if (eligiblePosts.length === 1)
    {
      const winningRunFinished =
	  nextWinningRunTargetId != null
	  && nextWinningRunStartAnswerCount != null
	  && eligiblePosts[0]?.id === nextWinningRunTargetId
	  && winningRunQuestionCount (
	      answers,
	      nextWinningRunStartAnswerCount) >= winningRunQuestionLimit
      if (winningRunFinished)
	return {
	    question: null,
	    guess: bestPost (eligiblePosts, scores),
	    guessReason: 'winning_run_finished',
	    questionMode: null,
	    questionPurpose: undefined,
	    effectiveQuestion: false,
	    learningQuestion: false,
	    winningRunTargetId: nextWinningRunTargetId,
	    winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
      if (!(nextWinningRunTargetPost) || nextWinningRunStartAnswerCount == null)
	return {
	    question: null,
	    guess: null,
	    guessReason: null,
	    questionMode: null,
	    questionPurpose: undefined,
	    effectiveQuestion: false,
	    learningQuestion: false,
	    winningRunTargetId: nextWinningRunTargetId,
	    winningRunStartAnswerCount: nextWinningRunStartAnswerCount }

      const winningRunQuestion = chooseWinningRunQuestion ({
	  posts,
	  targetPost: nextWinningRunTargetPost,
	  answers,
	  askedIds,
	  acceptedQuestions,
	  materialIndex,
	  matchIndex })
      if (winningRunQuestion)
	return {
	    question: winningRunQuestion,
	    guess: null,
	    guessReason: null,
	    questionMode: 'winning_run',
	    questionPurpose: undefined,
	    effectiveQuestion: false,
	    learningQuestion: false,
	    winningRunTargetId: nextWinningRunTargetId,
	    winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
      return {
	  question: null,
	  guess: null,
	  guessReason: null,
	  questionMode: null,
	  questionPurpose: undefined,
	  effectiveQuestion: false,
	  learningQuestion: false,
	  winningRunTargetId: nextWinningRunTargetId,
	  winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
    }

  const evaluationPosts =
      eligiblePosts

  const evaluationQuestions = buildQuestionsForPosts (evaluationPosts)
  const normalQuestionSelection = chooseQuestion ({
      posts: evaluationPosts,
      questions: evaluationQuestions,
      scores,
      answers,
      askedIds,
      gameSeed,
      recentFirstQuestionPenaltyById,
      userPriorWeights,
      materialIndex,
      matchIndex })

  const fallbackQuestion = normalQuestionSelection?.question ?? chooseFallbackQuestion ({
      posts: evaluationPosts,
      allPosts: posts,
      questions: evaluationQuestions,
      answers,
      askedIds,
      scores,
      materialIndex,
      matchIndex })

  if (fallbackQuestion)
    {
      return {
	  question: fallbackQuestion,
	  guess: null,
	  guessReason: null,
	  questionMode: 'normal',
	  questionPurpose: normalQuestionSelection?.question?.id === fallbackQuestion.id
	      ? normalQuestionSelection.questionPurpose
	      : 'normal',
	  effectiveQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id
	      ? normalQuestionSelection.effectiveQuestion
	      : false,
	  learningQuestion: normalQuestionSelection?.question?.id === fallbackQuestion.id
	      ? normalQuestionSelection.learningQuestion
	      : false,
	  winningRunTargetId: nextWinningRunTargetId,
	  winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
    }

  return {
      question: null,
      guess: null,
      guessReason: null,
      questionMode: null,
      questionPurpose: undefined,
      effectiveQuestion: false,
      learningQuestion: false,
      winningRunTargetId: nextWinningRunTargetId,
      winningRunStartAnswerCount: nextWinningRunStartAnswerCount }
}


const bestPost = (posts: Post[], scores: Map<number, number>): Post | null =>
    posts
    .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
    .sort ((a, b) => b.score - a.score)[0]?.post ?? null


const PostMiniCard: FC<{ post: Post }> = ({ post }) => (
    <div className="flex gap-3 items-center min-w-0">
      <img
	  src={post.thumbnail || post.thumbnailBase || undefined}
	  alt={post.title || post.url}
	  className="w-16 h-16 rounded object-cover bg-yellow-100"/>
      <div className="min-w-0">
	<PrefetchLink
	    to={`/posts/${ post.id }`}
	    className="font-bold text-pink-700 dark:text-pink-200 break-words">
	  #{post.id} {post.title || post.url}
	</PrefetchLink>
	<div className="text-sm text-neutral-600 dark:text-neutral-300 line-clamp-1">
	  {post.tags.slice (0, 6).map (tag => tag.name).join (' / ')}
	</div>
      </div>
    </div>)


const backgroundThumbnailUrl = (post: Post): string | undefined =>
    post.thumbnail || post.thumbnailBase || undefined


const mascotStateFor = (
    phase:                 Phase,
    resultWon:             boolean | null,
    eligiblePostCount:     number,
    bestConfidencePercent: number,
    winningRunActive:      boolean): MascotState => {
  const resultPhase =
      phase === 'end'
      || phase === 'review'
      || phase === 'learned'

  if (resultPhase && !(resultWon))
    return 'failed'

  if (resultPhase && resultWon)
    return 'celebrate'

  switch (phase)
  {
  case 'question':
  case 'continue':
  case 'extra_questions':
  case 'question_suggestion':
    if (
	winningRunActive
	|| eligiblePostCount <= 2
	|| bestConfidencePercent >= 70)
      return 'thinking_near'
    if (
	eligiblePostCount >= 15
	&& bestConfidencePercent < 45)
      return 'thinking_far'
    return 'thinking_mid'
  case 'guess':
  case 'end':
  case 'review':
  case 'learned':
    return 'confident'
  default:
    return 'idle'
  }
}


const backgroundPostsFor = ({
    phase,
    eligiblePosts,
    availablePosts,
    displayedGuess,
    reviewCorrectPost,
    reviewGuessedPost,
}: {
    phase:              Phase
    eligiblePosts:      Post[]
    availablePosts:     Post[]
    displayedGuess:     Post | null
    reviewCorrectPost:  Post | null
    reviewGuessedPost:  Post | null
}): Post[] => {
  const focusPosts =
      phase === 'end' || phase === 'review' || phase === 'learned'
      ? [reviewCorrectPost, reviewGuessedPost].filter ((post): post is Post => post != null)
      : phase === 'guess'
      ? [displayedGuess, ...eligiblePosts].filter ((post): post is Post => post != null)
      : eligiblePosts.length > 0
      ? eligiblePosts
      : availablePosts

  return [...new Map (focusPosts.map (post => [post.id, post])).values ()]
}


const GekanatorBackdrop: FC<{
    posts:                    Post[]
    mascotAsset:              string
    phase:                    Phase
    displayedGuess?:          Post | null
    visualSeed:               string
    motionMode:               BackgroundMotionMode
    winningRunTargetPost?:    Post | null
    winningRunQuestionCount?: number }> = ({ posts,
					     mascotAsset,
					     phase,
  					     displayedGuess = null,
  					     visualSeed,
  					     motionMode,
  					     winningRunTargetPost = null,
  					     winningRunQuestionCount = 0 }) => {
  const guessFocusOffset = useMemo (() => {
    const focusTiles = [
	{ x: 'calc(max(100vw, 100vh) * 0.5)',
	  y: 'calc(max(100vw, 100vh) * 0.5)' },
	{ x: 'calc(max(100vw, 100vh) * -0.5)',
	  y: 'calc(max(100vw, 100vh) * 0.5)' },
	{ x: 'calc(max(100vw, 100vh) * 0.5)',
	  y: 'calc(max(100vw, 100vh) * -0.5)' },
	{ x: 'calc(max(100vw, 100vh) * -0.5)',
	  y: 'calc(max(100vw, 100vh) * -0.5)' }]

    return (focusTiles[Math.abs (hashString (`${ visualSeed }:guess-focus`)) % focusTiles.length]
	    ?? focusTiles[0])
  }, [visualSeed])

  const directions = useMemo (
      () => [
	  { x: 0, y: -33.333333 },
	  { x: 33.333333, y: -33.333333 },
	  { x: 33.333333, y: 0 },
	  { x: 33.333333, y: 33.333333 },
	  { x: 0, y: 33.333333 },
	  { x: -33.333333, y: 33.333333 },
	  { x: -33.333333, y: 0 },
	  { x: -33.333333, y: -33.333333 }],
      [])
  const guessThumbnail =
      phase === 'guess' && displayedGuess ? backgroundThumbnailUrl (displayedGuess) : null
  const isWinningRunBackdrop =
      !(guessThumbnail)
      && phase === 'question'
      && winningRunTargetPost != null
      && Boolean (backgroundThumbnailUrl (winningRunTargetPost))
  const backdropMode = (() => {
    if (guessThumbnail)
      return 'guess'
    if (isWinningRunBackdrop)
      return 'winning_run'

    return 'normal'
  }) ()

  const normalVisiblePosts = useMemo (
      () => posts
	.filter (post => Boolean (backgroundThumbnailUrl (post)))
	.sort ((left, right) =>
	    hashString (`${ visualSeed }:${ left.id }`)
		- hashString (`${ visualSeed }:${ right.id }`))
	.slice (0, motionMode === 'calm' ? 24 : 36),
      [posts, visualSeed, motionMode])

  const settingsForMode = useCallback (
      (
	  mode: 'normal' | 'winning_run' | 'guess'): { columns: number; rows: number; opacity: number } => {
	if (mode === 'winning_run' || mode === 'guess')
	  return { columns: 8, rows: 8, opacity: motionMode === 'calm' ? .18 : .24 }

	if (motionMode === 'calm')
	  return { columns: 7, rows: 7, opacity: .14 }

	return { columns: 10, rows: 10, opacity: .2 }
      },
      [motionMode])

  const scaleForMode = useCallback (
      (
	  mode:                    'normal' | 'winning_run' | 'guess',
	  displayedWinningCount:   number): number => {
	if (mode === 'guess')
	  return 8

	if (mode === 'winning_run')
	  return [1, 8 / 6, 8 / 4, 8 / 2][Math.max (0, Math.min (3, displayedWinningCount))] ?? 1

	return 1
      },
      [])

  const postsForMode = useCallback ((
      mode: 'normal' | 'winning_run' | 'guess'): Post[] => {
    if (mode === 'guess' && displayedGuess)
      return [displayedGuess]
    if (mode === 'winning_run' && winningRunTargetPost)
      return [winningRunTargetPost]
    return normalVisiblePosts
  }, [displayedGuess, winningRunTargetPost, normalVisiblePosts])

  const thumbnailsForMode = useCallback ((
      mode:      'normal' | 'winning_run' | 'guess',
      count:     number): string[] => {
    const modePosts = postsForMode (mode)
    if (modePosts.length === 0)
      return []

    return Array.from ({ length: count }, (_, index) => {
      const post = modePosts[index % modePosts.length]
      return backgroundThumbnailUrl (post) ?? null
    }).filter ((thumbnail): thumbnail is string => Boolean (thumbnail))
  }, [postsForMode])

  const targetSettings = settingsForMode (backdropMode)
  const targetTileCount = targetSettings.columns * targetSettings.rows

  const nextThumbnails = useMemo (
      () => thumbnailsForMode (backdropMode, targetTileCount),
      [backdropMode, targetTileCount, thumbnailsForMode])

  const nextDirection = useMemo (
      () => directions[
	  Math.abs (hashString (`${ visualSeed }:direction`)) % directions.length]
	      ?? directions[0],
      [visualSeed, directions])

  const marqueeDuration = (() => {
    if (backdropMode === 'winning_run')
      return motionMode === 'calm' ? 28 : 20

    return motionMode === 'calm' ? 34 : 24
  }) ()
  const tileFlipDuration = motionMode === 'calm' ? .6 : .45
  const x = useMotionValue (0)
  const y = useMotionValue (0)
  const marqueeTransform = useMotionTemplate`translate(${ x }%, ${ y }%)`
  const [activeDirection, setActiveDirection] = useState (nextDirection)
  const activeDirectionRef = useRef (activeDirection)
  const guessAnimationControlsRef = useRef<ReturnType<typeof animate>[]> ([])
  const flipTimerRef = useRef<number | null> (null)
  const [displayedBackdropMode, setDisplayedBackdropMode] =
      useState<'normal' | 'winning_run' | 'guess'> (backdropMode)
  const [displayedWinningRunCount, setDisplayedWinningRunCount] =
      useState (winningRunQuestionCount)
  const [displayedThumbnails, setDisplayedThumbnails] = useState<string[]> (
      nextThumbnails)
  const [fromThumbnails, setFromThumbnails] = useState<string[]> (
      nextThumbnails)
  const [toThumbnails, setToThumbnails] = useState<string[]> (
      nextThumbnails)
  const [flipVisualSeed, setFlipVisualSeed] = useState (visualSeed)
  const [isFlippingTiles, setIsFlippingTiles] = useState (false)

  const renderedSettings = settingsForMode (displayedBackdropMode)
  const renderedTileCount = renderedSettings.columns * renderedSettings.rows
  const renderedScale = scaleForMode (displayedBackdropMode, displayedWinningRunCount)

  const isGuessPresentation =
      backdropMode === 'guess' || displayedBackdropMode === 'guess'

  useEffect (() => {
    guessAnimationControlsRef.current.forEach (control => control.stop ())
    guessAnimationControlsRef.current = []

    if (motionMode === 'off')
      return

    if (!(isGuessPresentation))
      return

    const duration = motionMode === 'calm' ? .95 : .75
    const ease = [0.16, 1, 0.3, 1] as const

    const controls = [
	animate (x, 0, { duration, ease }),
	animate (y, 0, { duration, ease })]
    guessAnimationControlsRef.current = controls

    return () => {
      controls.forEach (control => control.stop ())
      guessAnimationControlsRef.current = []
    }
  }, [isGuessPresentation, motionMode, visualSeed, x, y])

  useEffect (() => {
    activeDirectionRef.current = activeDirection
  }, [activeDirection])

  useEffect (() => {
    const wrap = (value: number): number => {
      const cell = 33.333333
      const wrapped = ((value % cell) + cell) % cell
      return wrapped > cell / 2 ? wrapped - cell : wrapped
    }

    if (motionMode === 'off' || nextThumbnails.length === 0)
      {
	guessAnimationControlsRef.current.forEach (control => control.stop ())
	guessAnimationControlsRef.current = []
	x.set (0)
	y.set (0)
	return
      }

    if (isGuessPresentation)
      {
	guessAnimationControlsRef.current.forEach (control => control.stop ())
	guessAnimationControlsRef.current = []
	x.set (0)
	y.set (0)
	return
      }

    const speed = 33.333333 / marqueeDuration
    let animationFrame: number
    let previousTime = performance.now ()

    const tick = (time: number) => {
      const elapsedSeconds = (time - previousTime) / 1000
      previousTime = time
      const direction = activeDirectionRef.current
      x.set (wrap (x.get () + Math.sign (direction.x) * speed * elapsedSeconds))
      y.set (wrap (y.get () + Math.sign (direction.y) * speed * elapsedSeconds))
      animationFrame = window.requestAnimationFrame (tick)
    }

    animationFrame = window.requestAnimationFrame (tick)

    return () => window.cancelAnimationFrame (animationFrame)
  }, [x,
      y,
      marqueeDuration,
      motionMode,
      isGuessPresentation,
      nextThumbnails.length])

  useEffect (() => {
    const applyDirection = () => {
      activeDirectionRef.current = nextDirection
      setActiveDirection (nextDirection)
    }

    if (flipTimerRef.current != null)
      {
	window.clearTimeout (flipTimerRef.current)
	flipTimerRef.current = null
      }

    if (motionMode === 'off')
      {
	applyDirection ()
	setIsFlippingTiles (false)
	setFlipVisualSeed (visualSeed)
	return
      }

    if (backdropMode === 'guess' && guessThumbnail)
      {
	setIsFlippingTiles (false)
	setDisplayedBackdropMode ('guess')
	setDisplayedWinningRunCount (winningRunQuestionCount)
	setDisplayedThumbnails (nextThumbnails)
	setFromThumbnails (nextThumbnails)
	setToThumbnails (nextThumbnails)
	setFlipVisualSeed (visualSeed)
	return
      }

    if (displayedBackdropMode === 'winning_run'
	&& backdropMode === 'winning_run')
      {
	applyDirection ()
	setDisplayedBackdropMode ('winning_run')
	setDisplayedWinningRunCount (winningRunQuestionCount)
	setDisplayedThumbnails (nextThumbnails)
	setFromThumbnails (nextThumbnails)
	setToThumbnails (nextThumbnails)
	setIsFlippingTiles (false)
	setFlipVisualSeed (visualSeed)
	return
      }

    if (nextThumbnails.length === 0)
      {
	applyDirection ()
	setIsFlippingTiles (false)
	setFlipVisualSeed (visualSeed)
	return
      }

    const sameTiles =
	displayedThumbnails.length === nextThumbnails.length
	&& displayedThumbnails.every ((thumbnail, index) => thumbnail === nextThumbnails[index])

    if (sameTiles && flipVisualSeed === visualSeed)
      {
	if (activeDirection.x !== nextDirection.x
	    || activeDirection.y !== nextDirection.y)
	  applyDirection ()

	return
      }

    const currentThumbnails =
	displayedThumbnails.length > 0 ? displayedThumbnails : nextThumbnails

    setFromThumbnails (currentThumbnails)
    setToThumbnails (nextThumbnails)
    setIsFlippingTiles (true)

    flipTimerRef.current = window.setTimeout (() => {
      setDisplayedBackdropMode (backdropMode)
      setDisplayedWinningRunCount (winningRunQuestionCount)
      setDisplayedThumbnails (nextThumbnails)
      setFromThumbnails (nextThumbnails)
      setToThumbnails (nextThumbnails)
      setIsFlippingTiles (false)
      applyDirection ()
      setFlipVisualSeed (visualSeed)
      flipTimerRef.current = null
    }, tileFlipDuration * 1000)

    return () => {
      if (flipTimerRef.current != null)
	{
	  window.clearTimeout (flipTimerRef.current)
	  flipTimerRef.current = null
	}
    }
  }, [motionMode,
      backdropMode,
      displayedBackdropMode,
      guessThumbnail,
      nextThumbnails,
      nextDirection,
      displayedThumbnails,
      flipVisualSeed,
      visualSeed,
      activeDirection,
      winningRunQuestionCount,
      tileFlipDuration,
      x,
      y])

  if (motionMode === 'off' || nextThumbnails.length === 0)
    return (
	<div className="absolute inset-0 bg-gradient-to-br from-yellow-50 via-white
			to-pink-50 dark:from-red-950 dark:via-red-975 dark:to-red-900"/>)

  const backdropTransition: Transition = (() => {
    if (displayedBackdropMode === 'winning_run' || displayedBackdropMode === 'guess')
      return { duration: motionMode === 'calm' ? .95 : .75, ease: [.16, 1, .3, 1] }

    return { duration: .2 }
  }) ()

  return (
      <div className="fixed [inset:48px_0_0_0] z-0 overflow-hidden pointer-events-none">
	<div className="absolute inset-0 flex items-center justify-center">
	  <motion.div
	      className="relative shrink-0"
	      style={{
		  transform: marqueeTransform,
		  width: 'calc(max(100vw, 100vh) * 3)',
		  height: 'calc(max(100vw, 100vh) * 3)' }}>
	    <motion.div
		className="relative h-full w-full"
		animate={{ scale: renderedScale,
			   x: displayedBackdropMode === 'guess' ? guessFocusOffset.x : '0%',
			   y: displayedBackdropMode === 'guess' ? guessFocusOffset.y : '0%' }}
		transition={backdropTransition}>
	      {Array.from ({ length: 9 }, (_, duplicate) => {
		const column = duplicate % 3
		const row = Math.floor (duplicate / 3)

		return (
		    <motion.div
			key={duplicate}
			className="absolute grid overflow-hidden"
			layout={displayedBackdropMode !== 'normal'}
			style={{
			    left: `${ column * 33.333333 }%`,
			    top: `${ row * 33.333333 }%`,
			    width: '33.333333%',
			    height: '33.333333%',
			    gridTemplateColumns:
				`repeat(${ renderedSettings.columns }, minmax(0, 1fr))`,
			    gridTemplateRows:
				`repeat(${ renderedSettings.rows }, minmax(0, 1fr))` }}
			transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}>
		      {Array.from ({ length: renderedTileCount }, (_, index) => {
			const currentThumbnail =
			    displayedThumbnails[
				index % Math.max (displayedThumbnails.length, 1)]
			const frontThumbnail =
			    isFlippingTiles
			    ? fromThumbnails[index % Math.max (fromThumbnails.length, 1)]
			    : currentThumbnail
			const backThumbnail =
			    isFlippingTiles
			    ? toThumbnails[index % Math.max (toThumbnails.length, 1)]
			    : currentThumbnail
			const thumbnail =
			    displayedBackdropMode === 'winning_run'
			    || displayedBackdropMode === 'guess'
			    ? nextThumbnails[index % Math.max (nextThumbnails.length, 1)]
			    : currentThumbnail
			if (!(thumbnail) || !(frontThumbnail) || !(backThumbnail))
			  return null

			const imageSource = ['intro', 'end'].includes (phase) ? mascotAsset : thumbnail
			const showStaticTile =
			    displayedBackdropMode !== 'normal' || !(isFlippingTiles)

			return (
			    <motion.div
				key={`${ duplicate }:${ index }`}
				className="relative overflow-hidden"
				layout={displayedBackdropMode !== 'normal'}
				transition={{ duration: tileFlipDuration, ease: 'easeInOut' }}
				style={{ perspective: 1600 }}>
			      {showStaticTile && (
				  <img
				      src={imageSource}
				      alt=""
				      className="absolute inset-0 h-full w-full object-cover"
				      style={{ opacity: renderedSettings.opacity }}/>)
			      }
			      {!(showStaticTile) && (
				  <motion.div
				      className="absolute inset-0"
				      initial={{ rotateY: 0 }}
				      animate={{ rotateY: 180 }}
				      transition={{
					  duration: tileFlipDuration,
					  ease: 'easeInOut' }}
				      style={{ transformStyle: 'preserve-3d' }}>
				    <img
					src={backThumbnail}
					alt=""
					className="absolute inset-0 h-full w-full object-cover"
					style={{
					    backfaceVisibility: 'hidden',
					    opacity: renderedSettings.opacity,
					    transform: 'rotateY(180deg)' }}/>
				    <img
					src={frontThumbnail}
					alt=""
					className="absolute inset-0 h-full w-full object-cover"
					style={{
					    backfaceVisibility: 'hidden',
					    opacity: renderedSettings.opacity }}/>
				  </motion.div>)}
			    </motion.div>)
		      })}
		    </motion.div>)
	      })}
	    </motion.div>
	  </motion.div>
	</div>
	<div className="fixed inset-0 z-0 bg-gradient-to-br from-yellow-50/76 via-white/58
			to-pink-100/62 dark:from-red-950/78 dark:via-red-975/60
			dark:to-red-900/66"/>
      </div>)
}


const expectedAnswerFor = (
    question:     GekanatorQuestion | undefined,
    correctPost:  Post | null): GekanatorAnswerValue | null =>
    expectedAnswerForQuestion (question, correctPost)


const GekanatorPage: FC<{ user: User | null }> = ({ user }) => {
  const storedGame = useMemo (loadStoredGame, [])
  const hasStoredRestore = storedGame != null && isStoredPhase (storedGame.phase)
  const queryClient = useQueryClient ()
  const isAdmin = user?.role === 'admin'
  const canPersistGame = user != null
  const [recentGames, setRecentGames] = useState<RecentGameSummary[]> (
      () => loadRecentGames ())
  const [backgroundMotionMode, setBackgroundMotionMode] = useState<BackgroundMotionMode> (
      () => loadBackgroundMotionMode ())
  const [prefersReducedMotion, setPrefersReducedMotion] = useState (false)
  const [gameSeed, setGameSeed] = useState (
      storedGame?.gameSeed ?? createGameSeed ())
  const [restorePromptVisible, setRestorePromptVisible] = useState (hasStoredRestore)
  const [phase, setPhase] = useState<Phase> (
      hasStoredRestore ? 'intro' : storedGame?.phase ?? 'intro')
  const [scores, setScores] = useState<Map<number, number>> (
      () => new Map (storedGame?.scores ?? []))
  const [answers, setAnswers] = useState<GekanatorAnswerLog[]> (
      storedGame?.answers ?? [])
  const [askedIds, setAskedIds] = useState<Set<string>> (
      () => new Set (storedGame?.askedIds ?? []))
  const [softenedQuestionIds, setSoftenedQuestionIds] = useState<Set<string>> (
      () => new Set (storedGame?.softenedQuestionIds ?? []))
  const [recoveredCandidatePosts, setRecoveredCandidatePosts] = useState<
      Map<number, RecoveredCandidateState>
  > (() => recoveredCandidateMapFromStored (
      storedGame?.recoveredCandidatePosts ?? [],
      storedGame?.scores ?? []))
  const [recoveryStepCount, setRecoveryStepCount] = useState (
      storedGame?.recoveryStepCount ?? 0)
  const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
      () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
  const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
      () => {
	if ((storedGame?.askedQuestionBank?.length ?? 0) > 0)
	  return []

	return storedGame?.askedQuestionBankIds ?? []
      })
  const [search, setSearch] = useState (storedGame?.search ?? '')
  const [selectingCorrectPost, setSelectingCorrectPost] = useState (
      storedGame?.selectingCorrectPost ?? false)
  const [saved, setSaved] = useState (storedGame?.saved ?? false)
  const [resultWon, setResultWon] = useState<boolean | null> (
      storedGame?.resultWon ?? null)
  const [rejectedPostIds, setRejectedPostIds] = useState<Set<number>> (
      () => new Set (storedGame?.rejectedPostIds ?? []))
  const [lastGuessQuestionCount, setLastGuessQuestionCount] = useState (
      storedGame?.lastGuessQuestionCount ?? 0)
  const [lastRejectedGuessId, setLastRejectedGuessId] = useState<number | null> (
      storedGame?.lastRejectedGuessId ?? null)
  const [winningRunTargetId, setWinningRunTargetId] = useState<number | null> (
      storedGame?.winningRunTargetId ?? null)
  const [winningRunStartAnswerCount, setWinningRunStartAnswerCount] =
      useState<number | null> (storedGame?.winningRunStartAnswerCount ?? null)
  const [guessReason, setGuessReason] = useState<GuessReason | null> (
      storedGame?.guessReason ?? null)
  const [activeGuessId, setActiveGuessId] = useState<number | null> (
      storedGame?.activeGuessId ?? null)
  const [reviewGuessedPostId, setReviewGuessedPostId] = useState<number | null> (
      storedGame?.reviewGuessedPostId ?? null)
  const [reviewCorrectPostId, setReviewCorrectPostId] = useState<number | null> (
      storedGame?.reviewCorrectPostId ?? null)
  const [savedGameId, setSavedGameId] = useState<number | null> (
      storedGame?.savedGameId ?? null)
  const [learnedExampleCount, setLearnedExampleCount] = useState<number | null> (
      storedGame?.learnedExampleCount ?? null)
  const [questionSuggestionEntryMode, setQuestionSuggestionEntryMode] =
      useState<QuestionSuggestionEntryMode> (
	  storedGame?.questionSuggestionEntryMode ?? 'search')
  const [questionSuggestionSearch, setQuestionSuggestionSearch] = useState (
      storedGame?.questionSuggestionSearch ?? '')
  const [questionSuggestionSelectedId, setQuestionSuggestionSelectedId] =
      useState<number | null> (storedGame?.questionSuggestionSelectedId ?? null)
  const [questionSuggestion, setQuestionSuggestion] = useState (
      storedGame?.questionSuggestion ?? '')
  const [questionSuggestionAnswer, setQuestionSuggestionAnswer] =
      useState<GekanatorAnswerValue> (storedGame?.questionSuggestionAnswer ?? 'yes')
  const [questionSuggestionCount, setQuestionSuggestionCount] = useState (
      storedGame?.questionSuggestionCount ?? 0)
  const [extraQuestions, setExtraQuestions] = useState<GekanatorExtraQuestion[]> (
      storedGame?.extraQuestions ?? [])
  const [extraQuestionAnswers, setExtraQuestionAnswers] =
      useState<Record<string, GekanatorAnswerValue>> (
	  storedGame?.extraQuestionAnswers ?? { })
  const [extraQuestionState, setExtraQuestionState] = useState<
      'idle' | 'loading' | 'ready' | 'empty' | 'saved'
  > (storedGame?.extraQuestionState ?? 'idle')
  const [history, setHistory] = useState<GameSnapshot[]> ([])

  const { data: posts = [], isLoading, error } = useQuery ({
      queryKey: gekanatorKeys.posts (),
      queryFn: fetchGekanatorPosts,
      refetchOnWindowFocus: false })
  const {
      data: acceptedQuestions = [],
      isFetched: acceptedQuestionsFetched,
      isLoading: acceptedQuestionsLoading,
      error: acceptedQuestionsError
  } = useQuery ({
      queryKey: gekanatorKeys.questions (),
      queryFn: fetchGekanatorQuestions,
      select: questions => questions.map (restoreGekanatorQuestion),
      refetchOnWindowFocus: false })
  const materialIndex = useMemo (() => buildMaterialIndex (posts), [posts])
  const acceptedQuestionMatchIndex = useMemo (
      () => buildGekanatorMatchIndex (posts, acceptedQuestions),
      [posts, acceptedQuestions])

  useEffect (() => {
    if (posts.length === 0
	|| storedAskedQuestionBankIds.length === 0
	|| !(acceptedQuestionsFetched))
      return

    const questionById = new Map (
	mergeQuestions ([
	    ...acceptedQuestions,
	    ...askedQuestionBank])
	    .map (question => [question.id, question]))
    setAskedQuestionBank (
	storedAskedQuestionBankIds
	    .map (questionId => questionById.get (questionId))
	    .filter ((question): question is GekanatorQuestion => question != null))
    setStoredAskedQuestionBankIds ([])
  }, [posts,
      storedAskedQuestionBankIds,
      acceptedQuestionsFetched,
      askedQuestionBank,
      acceptedQuestions])

  useEffect (() => {
    if (restorePromptVisible)
      return

    if (!(isStoredPhase (phase)) && answers.length === 0)
      {
	clearStoredGame ()
	return
      }

    const stored: StoredGekanatorGame = {
	phase,
	scores: [...scores.entries ()],
	answers,
	askedIds: [...askedIds],
	softenedQuestionIds: [...softenedQuestionIds],
	recoveredCandidatePosts: storedRecoveredCandidatesFromMap (recoveredCandidatePosts),
	recoveryStepCount,
	askedQuestionBank: askedQuestionBank.map (storeGekanatorQuestion),
	askedQuestionBankIds: storedAskedQuestionBankIds,
	search,
	selectingCorrectPost,
	saved,
	resultWon,
	rejectedPostIds: [...rejectedPostIds],
	lastGuessQuestionCount,
	lastRejectedGuessId,
	winningRunTargetId,
	winningRunStartAnswerCount,
	guessReason,
	activeGuessId,
	reviewGuessedPostId,
	reviewCorrectPostId,
	savedGameId,
	learnedExampleCount,
	gameSeed,
	questionSuggestionEntryMode,
	questionSuggestionSearch,
	questionSuggestionSelectedId,
	questionSuggestion,
	questionSuggestionAnswer,
	questionSuggestionCount,
	extraQuestions,
	extraQuestionAnswers,
	extraQuestionState }

    try
    {
      sessionStorage.setItem (gameStorageKey, JSON.stringify (stored))
    }
    catch
    {
      return
    }
  }, [
      phase,
      scores,
      answers,
      askedIds,
      softenedQuestionIds,
      recoveredCandidatePosts,
      recoveryStepCount,
      askedQuestionBank,
      storedAskedQuestionBankIds,
      search,
      selectingCorrectPost,
      saved,
      resultWon,
      rejectedPostIds,
      lastGuessQuestionCount,
      lastRejectedGuessId,
      winningRunTargetId,
      winningRunStartAnswerCount,
      guessReason,
      activeGuessId,
      reviewGuessedPostId,
      reviewCorrectPostId,
      savedGameId,
      learnedExampleCount,
      gameSeed,
      questionSuggestionEntryMode,
      questionSuggestionSearch,
      questionSuggestionSelectedId,
      questionSuggestion,
      questionSuggestionAnswer,
      questionSuggestionCount,
      extraQuestions,
      extraQuestionAnswers,
      extraQuestionState,
      restorePromptVisible])

  useEffect (() => {
    if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
      return

    const media = window.matchMedia ('(prefers-reduced-motion: reduce)')
    const sync = () => setPrefersReducedMotion (media.matches)

    sync ()
    media.addEventListener ('change', sync)
    return () => media.removeEventListener ('change', sync)
  }, [])

  useEffect (() => {
    try
    {
      setClientGekanatorBackgroundMotion (backgroundMotionMode)
    }
    catch
    {
      return
    }
  }, [backgroundMotionMode])

  const askedQuestionById = useMemo (
      () => new Map (askedQuestionBank.map (question => [question.id, question])),
      [askedQuestionBank])
  const eligiblePosts = useMemo (
      () => candidatePostsForState ({
	  posts,
	  questionById: askedQuestionById,
	  materialIndex,
	  matchIndex: acceptedQuestionMatchIndex,
	  answers,
	  softenedQuestionIds,
	  rejectedPostIds,
	  recoveredCandidatePosts,
	  scores }),
      [posts, askedQuestionById, materialIndex, acceptedQuestionMatchIndex,
	  answers, softenedQuestionIds, rejectedPostIds, recoveredCandidatePosts, scores])
  const scoringQuestions = useMemo (() => {
    return mergeQuestions ([...acceptedQuestions, ...askedQuestionBank])
  }, [acceptedQuestions, askedQuestionBank])
  const scoringQuestionById = useMemo (
      () => new Map (scoringQuestions.map (question => [question.id, question])),
      [scoringQuestions])
  const searchableSuggestedQuestions = useMemo (
      () => searchedQuestionsFor (acceptedQuestions, questionSuggestionSearch),
      [acceptedQuestions, questionSuggestionSearch])
  const selectedSuggestedQuestion = useMemo (
      () => acceptedQuestions.find (
	  question => question.recordId === questionSuggestionSelectedId)
	  ?? null,
      [acceptedQuestions, questionSuggestionSelectedId])
  const canSubmitQuestionSuggestion = useMemo (() => {
    if (!(canPersistGame) || reviewCorrectPostId == null)
      return false

    if (questionSuggestionEntryMode === 'search')
      return selectedSuggestedQuestion != null

    return questionSuggestion.trim () !== ''
  }, [
      canPersistGame,
      reviewCorrectPostId,
      questionSuggestionEntryMode,
      selectedSuggestedQuestion,
      questionSuggestion])
  const recentFirstQuestionPenaltyById = useMemo (() => {
    const penalties = new Map<string, number> ()

    recentGames.forEach ((game, index) => {
      if (!(game.firstQuestionId))
	return

      penalties.set (
	  game.firstQuestionId,
	  (penalties.get (game.firstQuestionId) ?? 0) + Math.max (.2, 1 - index * .22))
    })

    return penalties
  }, [recentGames])
  const userPriorWeights = useMemo (
      () => userPriorWeightsFor (posts, recentGames),
      [posts, recentGames])
  const availablePosts = useMemo (
      () => posts.filter (post => !(rejectedPostIds.has (post.id))),
      [posts, rejectedPostIds])
  const questionPlan = useMemo (
      () => nextQuestionPlanFor ({
	  posts,
	  eligiblePosts,
	  availablePosts,
	  acceptedQuestions,
	  scores,
	  answers,
	  askedIds,
	  gameSeed,
	  recentFirstQuestionPenaltyById,
	  userPriorWeights,
	  materialIndex,
	  matchIndex: acceptedQuestionMatchIndex,
	  lastGuessQuestionCount,
	  winningRunTargetId,
	  winningRunStartAnswerCount }),
      [posts, eligiblePosts, availablePosts, acceptedQuestions, scores,
	  answers, askedIds, gameSeed, recentFirstQuestionPenaltyById,
	  userPriorWeights, materialIndex, acceptedQuestionMatchIndex,
	  lastGuessQuestionCount, winningRunTargetId, winningRunStartAnswerCount])
  const winningRunTargetPost = useMemo (
      () => {
	if (questionPlan.winningRunTargetId == null)
	  return null

	return posts.find (post => post.id === questionPlan.winningRunTargetId) ?? null
      },
      [posts, questionPlan.winningRunTargetId])
  const winningRunQuestionsAsked = winningRunQuestionCount (
      answers,
      questionPlan.winningRunStartAnswerCount)
  const winningRunActive =
      isWinningRunActive (
	  questionPlan.winningRunTargetId,
	  questionPlan.winningRunStartAnswerCount)
      && winningRunQuestionsAsked < winningRunQuestionLimit
      && eligiblePosts.length === 1
      && eligiblePosts[0]?.id === questionPlan.winningRunTargetId
      && winningRunTargetPost != null
  const topScoredPosts = useMemo (
      () => eligiblePosts
      .map (post => ({ post, score: scores.get (post.id) ?? 0 }))
      .sort ((a, b) => b.score - a.score)
      .slice (0, 3),
      [eligiblePosts, scores])
  const currentQuestion = questionPlan.question
  const answerPreviews = useMemo (
      () => {
	if (!(isAdmin) || !(currentQuestion))
	  return []

	return answerOptions.map (option => previewAnswer ({
	    posts: eligiblePosts,
	    scores,
	    question: currentQuestion,
	    answer: option.value,
	    materialIndex,
	    matchIndex: acceptedQuestionMatchIndex }))
      },
      [isAdmin, currentQuestion, eligiblePosts, materialIndex,
	  acceptedQuestionMatchIndex, scores])
  const guessablePosts = eligiblePosts.length > 0 ? eligiblePosts : availablePosts
  const guessConfidences = useMemo (
      () => confidencesFor (guessablePosts, scores),
      [guessablePosts, scores])
  const bestConfidencePercent = guessConfidences[0]?.percent ?? 0
  const guess = bestPost (guessablePosts, scores)
  const displayedGuess =
      posts.find (post => post.id === activeGuessId) ?? guess
  const reviewGuessedPost =
      posts.find (post => post.id === reviewGuessedPostId) ?? null
  const reviewCorrectPost =
      posts.find (post => post.id === reviewCorrectPostId) ?? null
  const effectiveResultWon = (() => {
    if (resultWon != null)
      return resultWon
    if (reviewGuessedPostId == null || reviewCorrectPostId == null)
      return null

    return reviewGuessedPostId === reviewCorrectPostId
  }) ()
  const effectiveBackgroundMotionMode = (() => {
    if (backgroundMotionMode === 'off')
      return 'off'
    if (prefersReducedMotion)
      return 'calm'

    return backgroundMotionMode
  }) ()
  const backgroundPosts = useMemo (
      () => backgroundPostsFor ({
	      phase,
	      eligiblePosts,
	      availablePosts,
	      displayedGuess,
	      reviewCorrectPost,
	      reviewGuessedPost }),
      [phase, eligiblePosts, availablePosts, displayedGuess, reviewCorrectPost,
	  reviewGuessedPost])
  const backgroundVisualSeed =
	`${ gameSeed }:${ phase }:${ answers.length }:${ activeGuessId ?? '' }:${
	    questionPlan.question?.id ?? ''
	}:${ questionPlan.questionMode ?? '' }:${ winningRunQuestionsAsked }:${
	    rejectedPostIds.size
	}:${ backgroundPosts.slice (0, 8).map (post => post.id).join ('|') }`
  const mascot = mascotStateFor (phase, effectiveResultWon, eligiblePosts.length,
				 bestConfidencePercent, winningRunActive)
  const mascotAsset = mascotAssetByState[mascot]
  const mascotAlt = mascotAltByState[mascot]
  const saveMutation = useMutation ({
      mutationFn: saveGekanatorGame,
      onSuccess: (data, variables) => {
	setRecentGames (storeRecentGameSummary ({
	    correctPostId: variables.correctPostId,
	    firstQuestionId: variables.answers[0]?.questionId ?? null,
	    savedAt: Date.now () }))
	setSaved (true)
	setSavedGameId (data.id)
	setLearnedExampleCount (data.learnedExampleCount)
	setResultWon (variables.guessedPostId === variables.correctPostId)}})
  const questionSuggestionMutation = useMutation ({
      mutationFn: saveGekanatorQuestionSuggestion,
      onSuccess: async data => {
	await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
	setQuestionSuggestionCount (data.count)
	setQuestionSuggestionEntryMode ('search')
	setQuestionSuggestionSearch ('')
	setQuestionSuggestionSelectedId (null)
	setQuestionSuggestion ('')
	setQuestionSuggestionAnswer ('yes')}})
  const extraQuestionAnswersMutation = useMutation ({
      mutationFn: saveGekanatorExtraQuestionAnswers,
      onSuccess: async () => {
	await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
	setExtraQuestionState ('saved')
	setPhase ('end')}})

  const resetExtraQuestionState = () => {
    const next = resettableExtraQuestionState ()
    setExtraQuestions (next.extraQuestions.slice (0, 2))
    setExtraQuestionAnswers (next.extraQuestionAnswers)
    setExtraQuestionState (next.extraQuestionState)
    extraQuestionAnswersMutation.reset ()
  }

  const reset = () => {
    clearStoredGame ()
    saveMutation.reset ()
    questionSuggestionMutation.reset ()
    setRestorePromptVisible (false)
    setPhase ('intro')
    setScores (new Map ())
    setAnswers ([])
    setAskedIds (new Set ())
    setSoftenedQuestionIds (new Set ())
    setRecoveredCandidatePosts (new Map ())
    setRecoveryStepCount (0)
    setAskedQuestionBank ([])
    setSearch ('')
    setSelectingCorrectPost (false)
    setSaved (false)
    setResultWon (null)
    setRejectedPostIds (new Set ())
    setLastGuessQuestionCount (0)
    setLastRejectedGuessId (null)
    setWinningRunTargetId (null)
    setWinningRunStartAnswerCount (null)
    setGuessReason (null)
    setActiveGuessId (null)
    setReviewGuessedPostId (null)
    setReviewCorrectPostId (null)
    setSavedGameId (null)
    setLearnedExampleCount (null)
    setGameSeed (createGameSeed ())
    setQuestionSuggestionEntryMode ('search')
    setQuestionSuggestionSearch ('')
    setQuestionSuggestionSelectedId (null)
    setQuestionSuggestion ('')
    setQuestionSuggestionAnswer ('yes')
    setQuestionSuggestionCount (0)
    resetExtraQuestionState ()
    setHistory ([])
  }

  const continueStoredGame = () => {
    setRestorePromptVisible (false)
    setPhase (storedGame?.phase ?? 'question')
  }

  const recoverQuestionState = useCallback (({
      nextAnswers,
      nextAskedIds,
      nextAskedQuestionBank,
      nextSoftenedQuestionIds,
      nextRejectedPostIds,
      nextRecoveredCandidatePosts,
      nextRecoveryStepCount,
      allowPreQuestionRecovery,
  }: {
      nextAnswers:              GekanatorAnswerLog[]
      nextAskedIds:             Set<string>
      nextAskedQuestionBank:    GekanatorQuestion[]
      nextSoftenedQuestionIds:  Set<string>
      nextRejectedPostIds:      Set<number>
      nextRecoveredCandidatePosts: Map<number, RecoveredCandidateState>
      nextRecoveryStepCount:    number
      allowPreQuestionRecovery?: boolean
  }) => {
    let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
    let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts)
    let recoveredStepCount = nextRecoveryStepCount
    const nextAskedQuestionById =
	new Map (nextAskedQuestionBank.map (question => [question.id, question]))
    const answerCountAtRecovery = (() => {
      if (allowPreQuestionRecovery)
	return nextAnswers.length

      return Math.max (nextAnswers.length - 1, 0)
    }) ()
    let recoveredScores = recalculateScores ({
	posts,
	questions: nextAskedQuestionBank,
	answers: nextAnswers,
	softenedQuestionIds: recoveredSoftenedQuestionIds,
	materialIndex,
	matchIndex: acceptedQuestionMatchIndex })
    let recoveredEligiblePosts = candidatePostsForState ({
	posts,
	questionById: nextAskedQuestionById,
	materialIndex,
	matchIndex: acceptedQuestionMatchIndex,
	answers: nextAnswers,
	softenedQuestionIds: recoveredSoftenedQuestionIds,
	rejectedPostIds: nextRejectedPostIds,
	recoveredCandidatePosts,
	scores: recoveredScores })
    let recoveredQuestions = buildQuestionsForCandidateIds ({
	candidateIds: recoveredEligiblePosts.map (post => post.id),
	materialIndex,
	acceptedQuestions,
	mode: 'split' })
    let recoveredScoringQuestions = mergeQuestions ([
	...recoveredQuestions,
	...nextAskedQuestionBank])

    const refreshRecoveredState = () => {
      recoveredScores = recalculateScores ({
	  posts,
	  questions: nextAskedQuestionBank,
	  answers: nextAnswers,
	  softenedQuestionIds: recoveredSoftenedQuestionIds,
	  materialIndex,
	  matchIndex: acceptedQuestionMatchIndex })
      recoveredEligiblePosts = candidatePostsForState ({
	  posts,
	  questionById: nextAskedQuestionById,
	  materialIndex,
	  matchIndex: acceptedQuestionMatchIndex,
	  answers: nextAnswers,
	  softenedQuestionIds: recoveredSoftenedQuestionIds,
	  rejectedPostIds: nextRejectedPostIds,
	  recoveredCandidatePosts,
	  scores: recoveredScores })
      recoveredQuestions = buildQuestionsForCandidateIds ({
	  candidateIds: recoveredEligiblePosts.map (post => post.id),
	  materialIndex,
	  acceptedQuestions,
	  mode: 'split' })
      recoveredScoringQuestions = mergeQuestions ([
	  ...recoveredQuestions,
	  ...nextAskedQuestionBank])
    }

    const needsPreQuestionRecovery = () => {
      if (
	  !(allowPreQuestionRecovery)
	  || recoveredEligiblePosts.length === 0
	  || recoveredEligiblePosts.length === 1)
	return false

      const nextQuestion = chooseQuestion ({
	  posts: recoveredEligiblePosts,
	  questions: recoveredScoringQuestions,
	  scores: recoveredScores,
	  answers: nextAnswers,
	  askedIds: nextAskedIds,
	  gameSeed,
	  recentFirstQuestionPenaltyById,
	  userPriorWeights,
	  materialIndex,
	  matchIndex: acceptedQuestionMatchIndex })
      const fallbackQuestion = nextQuestion?.question ?? chooseFallbackQuestion ({
	  posts: recoveredEligiblePosts,
	  allPosts: posts,
	  questions: recoveredScoringQuestions,
	  answers: nextAnswers,
	  askedIds: nextAskedIds,
	  scores: recoveredScores,
	  materialIndex,
	  matchIndex: acceptedQuestionMatchIndex })

      return !(fallbackQuestion)
	  || !(hasDiscriminatingHardSplitForQuestion ({
	      candidateIds: recoveredEligiblePosts.map (post => post.id),
	      question: fallbackQuestion,
	      posts,
	      materialIndex,
	      matchIndex: acceptedQuestionMatchIndex }))
    }

    while (recoveredEligiblePosts.length === 0 || needsPreQuestionRecovery ())
      {
	const recoveredPosts = recoverCandidatePosts ({
	    posts,
	    scores: recoveredScores,
	    rejectedPostIds: nextRejectedPostIds,
	    recoveredCandidatePosts,
	    eligiblePostIds: new Set (recoveredEligiblePosts.map (post => post.id)),
	    answerCountAtRecovery,
	    recoveryStepCount: recoveredStepCount })
	if (recoveredPosts)
	  {
	    recoveredCandidatePosts = recoveredPosts.recoveredCandidatePosts
	    recoveredStepCount = recoveredPosts.recoveryStepCount
	    refreshRecoveredState ()
	    if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ()))
	      break
	  }

	if (nextAnswers.length >= hardMaxQuestions)
	  break

	if (recoveredEligiblePosts.length > 0 && !(needsPreQuestionRecovery ()))
	  break

	const softened = softenNextQuestionIds ({
	    questions: nextAskedQuestionBank,
	    answers: nextAnswers,
	    softenedQuestionIds: recoveredSoftenedQuestionIds })
	if (!(softened))
	  break

	recoveredSoftenedQuestionIds = softened
	refreshRecoveredState ()
      }

    return {
	softenedQuestionIds: recoveredSoftenedQuestionIds,
	recoveredCandidatePosts,
	recoveryStepCount: recoveredStepCount,
	scores: recoveredScores,
	eligiblePosts: recoveredEligiblePosts,
	scoringQuestions: recoveredScoringQuestions }
  }, [
      posts,
      gameSeed,
      materialIndex,
      acceptedQuestions,
      acceptedQuestionMatchIndex,
      recentFirstQuestionPenaltyById,
      userPriorWeights])

  const answer = (value: GekanatorAnswerValue) => {
    if (!(currentQuestion))
      {
	if (questionPlan.guess && shouldEnterGuessPhase (questionPlan.guessReason))
	  {
	    setActiveGuessId (questionPlan.guess.id)
	    setLastGuessQuestionCount (answers.length)
	    setGuessReason (questionPlan.guessReason)
	    setPhase ('guess')
	  }
	return
      }

    setHistory ([...history, {
	phase,
	scores: new Map (scores),
	answers: [...answers],
	askedIds: new Set (askedIds),
	softenedQuestionIds: new Set (softenedQuestionIds),
	recoveredCandidatePosts: new Map (recoveredCandidatePosts),
	recoveryStepCount,
	askedQuestionBank: [...askedQuestionBank],
	search,
	selectingCorrectPost,
	rejectedPostIds: new Set (rejectedPostIds),
	lastGuessQuestionCount,
	lastRejectedGuessId,
	winningRunTargetId,
	winningRunStartAnswerCount,
	guessReason,
	activeGuessId,
	reviewGuessedPostId,
	reviewCorrectPostId }])
    const nextAnswers = [...answers, {
	questionId:      currentQuestion.id,
	questionText:    currentQuestion.text,
	questionCondition: currentQuestion.condition,
	questionMode:    questionPlan.questionMode ?? undefined,
	questionPurpose: questionPlan.questionPurpose,
	effectiveQuestion: questionPlan.effectiveQuestion,
	learningQuestion: questionPlan.learningQuestion,
	answer:          value,
	originalAnswer:  value }]
    const nextAskedIds = new Set ([...askedIds, currentQuestion.id])
    const nextAskedQuestionBank = [
	...askedQuestionBank.filter (question => question.id !== currentQuestion.id),
	currentQuestion]
    const recovered = recoverQuestionState ({
	nextAnswers,
	nextAskedIds,
	nextAskedQuestionBank,
	nextSoftenedQuestionIds: softenedQuestionIds,
	nextRejectedPostIds: rejectedPostIds,
	nextRecoveredCandidatePosts: recoveredCandidatePosts,
	nextRecoveryStepCount: recoveryStepCount })
    const nextEligiblePosts = recovered.eligiblePosts
    let nextPlan = nextQuestionPlanFor ({
	posts,
	eligiblePosts: nextEligiblePosts,
	availablePosts,
	acceptedQuestions,
	scores: recovered.scores,
	answers: nextAnswers,
	askedIds: nextAskedIds,
	gameSeed,
	recentFirstQuestionPenaltyById,
	userPriorWeights,
	materialIndex,
	matchIndex: acceptedQuestionMatchIndex,
	lastGuessQuestionCount,
	winningRunTargetId,
	winningRunStartAnswerCount })
    let finalRecovered = recovered

    if (
	!(nextPlan.question)
	&& !(shouldEnterGuessPhase (nextPlan.guessReason))
	&& recovered.eligiblePosts.length !== 1)
      {
	const recoveredForQuestion = recoverQuestionState ({
	    nextAnswers,
	    nextAskedIds,
	    nextAskedQuestionBank,
	    nextSoftenedQuestionIds: recovered.softenedQuestionIds,
	    nextRejectedPostIds: rejectedPostIds,
	    nextRecoveredCandidatePosts: recovered.recoveredCandidatePosts,
	    nextRecoveryStepCount: recovered.recoveryStepCount,
	    allowPreQuestionRecovery: true })
	nextPlan = nextQuestionPlanFor ({
	    posts,
	    eligiblePosts: recoveredForQuestion.eligiblePosts,
	    availablePosts,
	    acceptedQuestions,
	    scores: recoveredForQuestion.scores,
	    answers: nextAnswers,
	    askedIds: nextAskedIds,
	    gameSeed,
	    recentFirstQuestionPenaltyById,
	    userPriorWeights,
	    materialIndex,
	    matchIndex: acceptedQuestionMatchIndex,
	    lastGuessQuestionCount,
	    winningRunTargetId,
	    winningRunStartAnswerCount })
	finalRecovered = recoveredForQuestion
      }

    setScores (finalRecovered.scores)
    setAskedIds (nextAskedIds)
    setSoftenedQuestionIds (finalRecovered.softenedQuestionIds)
    setRecoveredCandidatePosts (finalRecovered.recoveredCandidatePosts)
    setRecoveryStepCount (finalRecovered.recoveryStepCount)
    setAskedQuestionBank (nextAskedQuestionBank)
    setAnswers (nextAnswers)
    setWinningRunTargetId (nextPlan.winningRunTargetId)
    setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount)

    if (nextPlan.question)
      {
	setGuessReason (null)
	setActiveGuessId (null)
	setPhase ('question')
	return
      }

    setGuessReason (nextPlan.guessReason)
    if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason))
      {
	setActiveGuessId (nextPlan.guess.id)
	setLastGuessQuestionCount (nextAnswers.length)
	setPhase ('guess')
      }
  }

  const finishGame = (correctPostId: number) => {
    const guessedPostId = (() => {
      if (phase === 'end' || phase === 'review')
	return reviewGuessedPostId
      if (phase === 'continue')
	return lastRejectedGuessId ?? displayedGuess?.id

      return displayedGuess?.id ?? lastRejectedGuessId
    }) ()
    if (!(guessedPostId))
      return

    saveMutation.reset ()
    questionSuggestionMutation.reset ()
    resetExtraQuestionState ()
    setSaved (false)
    setSavedGameId (null)
    setLearnedExampleCount (null)
    setQuestionSuggestionEntryMode ('search')
    setQuestionSuggestionSearch ('')
    setQuestionSuggestionSelectedId (null)
    setReviewGuessedPostId (guessedPostId)
    setReviewCorrectPostId (correctPostId)
    setSearch ('')
    setSelectingCorrectPost (false)
    setPhase ('end')
  }

  const startReview = () => {
    if (reviewGuessedPostId == null || reviewCorrectPostId == null)
      return

    saveMutation.reset ()
    questionSuggestionMutation.reset ()
    resetExtraQuestionState ()
    setSaved (false)
    setSavedGameId (null)
    setLearnedExampleCount (null)
    setQuestionSuggestionEntryMode ('search')
    setQuestionSuggestionSearch ('')
    setQuestionSuggestionSelectedId (null)
    setSelectingCorrectPost (false)
    setSearch ('')
    setPhase ('review')
  }

  const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
    if (
	!(canPersistGame)
	|| reviewGuessedPostId == null
	|| reviewCorrectPostId == null
	|| saveMutation.isPending)
      return

    if (savedGameId != null)
      {
	onSuccess (savedGameId)
	return
      }

    saveMutation.mutate ({
	guessedPostId: reviewGuessedPostId,
	correctPostId: reviewCorrectPostId,
	answers },
	{ onSuccess: data => onSuccess (data.id) })
  }

  const saveAndReset = () => {
    if (saveMutation.isError)
      {
	reset ()
	return
      }

    if (!(canPersistGame))
      {
	reset ()
	return
      }

    saveReviewedResult (reset)
  }

  const saveAndLearn = () => {
    if (!(canPersistGame))
      {
	setPhase ('end')
	return
      }

    resetExtraQuestionState ()
    saveReviewedResult (() => setPhase ('end'))
  }

  const submitQuestionSuggestion = () => {
    const questionText = questionSuggestion.trim ()
    const selectedQuestion = selectedSuggestedQuestion
    if (!(canSubmitQuestionSuggestion) || questionSuggestionMutation.isPending)
      return

    saveReviewedResult (gekanatorGameId => {
      questionSuggestionMutation.mutate ({
	  gekanatorGameId,
	  existingQuestionId: selectedQuestion?.recordId,
	  questionText: selectedQuestion ? undefined : questionText,
	  answer: questionSuggestionAnswer })
    })
  }

  const saveExtraQuestions = () => {
    if (
	!(canPersistGame)
	|| savedGameId == null
	|| extraQuestionAnswersMutation.isPending
	|| extraQuestions.some (question => !(extraQuestionAnswers[String (question.id)])))
      return

    extraQuestionAnswersMutation.mutate ({
	gameId: savedGameId,
	answers: extraQuestions.map (question => ({
	    questionId: question.id,
	    answer: extraQuestionAnswers[String (question.id)] })) })
  }

  const rejectGuess = () => {
    if (!(displayedGuess))
      return

    setLastRejectedGuessId (displayedGuess.id)
    if (answers.length >= hardMaxQuestions)
      {
	setSelectingCorrectPost (true)
	return
      }

    setRejectedPostIds (new Set ([...rejectedPostIds, displayedGuess.id]))
    setRecoveredCandidatePosts (
	new Map (
	    [...recoveredCandidatePosts.entries ()].filter (
		([postId]) => postId !== displayedGuess.id)))
    setWinningRunTargetId (null)
    setWinningRunStartAnswerCount (null)
    setGuessReason (null)
    setActiveGuessId (null)
    setSearch ('')
    setSelectingCorrectPost (false)
    setLastGuessQuestionCount (answers.length)
    setPhase ('continue')
  }

  const undoAnswer = () => {
    const snapshot = history[history.length - 1]
    if (!(snapshot) || saved)
      return

    setPhase (snapshot.phase)
    setScores (snapshot.scores)
    setAnswers (snapshot.answers)
    setAskedIds (snapshot.askedIds)
    setSoftenedQuestionIds (snapshot.softenedQuestionIds)
    setRecoveredCandidatePosts (snapshot.recoveredCandidatePosts)
    setRecoveryStepCount (snapshot.recoveryStepCount)
    setAskedQuestionBank (snapshot.askedQuestionBank)
    setSearch (snapshot.search)
    setSelectingCorrectPost (snapshot.selectingCorrectPost)
    setRejectedPostIds (snapshot.rejectedPostIds)
    setLastGuessQuestionCount (snapshot.lastGuessQuestionCount)
    setLastRejectedGuessId (snapshot.lastRejectedGuessId)
    setWinningRunTargetId (snapshot.winningRunTargetId)
    setWinningRunStartAnswerCount (snapshot.winningRunStartAnswerCount)
    setGuessReason (snapshot.guessReason)
    setActiveGuessId (snapshot.activeGuessId)
    setReviewGuessedPostId (snapshot.reviewGuessedPostId)
    setReviewCorrectPostId (snapshot.reviewCorrectPostId)
    setHistory (history.slice (0, -1))
  }

  const continueGame = () => {
    setSearch ('')
    setSelectingCorrectPost (false)

    const recovered = recoverQuestionState ({
	nextAnswers: answers,
	nextAskedIds: askedIds,
	nextAskedQuestionBank: askedQuestionBank,
	nextSoftenedQuestionIds: softenedQuestionIds,
	nextRejectedPostIds: rejectedPostIds,
	nextRecoveredCandidatePosts: recoveredCandidatePosts,
	nextRecoveryStepCount: recoveryStepCount,
	allowPreQuestionRecovery: true })

    setSoftenedQuestionIds (recovered.softenedQuestionIds)
    setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
    setRecoveryStepCount (recovered.recoveryStepCount)
    setScores (recovered.scores)
    const nextPlan = nextQuestionPlanFor ({
	posts,
	eligiblePosts: recovered.eligiblePosts,
	availablePosts,
	acceptedQuestions,
	scores: recovered.scores,
	answers,
	askedIds,
	gameSeed,
	recentFirstQuestionPenaltyById,
	userPriorWeights,
	materialIndex,
	matchIndex: acceptedQuestionMatchIndex,
	lastGuessQuestionCount,
	winningRunTargetId,
	winningRunStartAnswerCount })

    setWinningRunTargetId (nextPlan.winningRunTargetId)
    setWinningRunStartAnswerCount (nextPlan.winningRunStartAnswerCount)

    if (nextPlan.question)
      {
	setGuessReason (null)
	setActiveGuessId (null)
	setPhase ('question')
	return
      }

    setGuessReason (nextPlan.guessReason)
    if (nextPlan.guess && shouldEnterGuessPhase (nextPlan.guessReason))
      {
	setActiveGuessId (nextPlan.guess.id)
	setLastGuessQuestionCount (answers.length)
	setPhase ('guess')
      }
  }

  const correctAnswerAt = (index: number, value: GekanatorAnswerValue) => {
    setSaved (false)
    setSavedGameId (null)
    setLearnedExampleCount (null)
    setQuestionSuggestionEntryMode ('search')
    setQuestionSuggestionSearch ('')
    setQuestionSuggestionSelectedId (null)
    resetExtraQuestionState ()
    setAnswers (answers.map ((answer, i) =>
	i === index ? { ...answer, answer: value } : answer))
  }

  const selectCorrectPost = (post: Post) => {
    if (phase === 'review')
      {
	setSaved (false)
	setSavedGameId (null)
	setLearnedExampleCount (null)
	setQuestionSuggestionEntryMode ('search')
	setQuestionSuggestionSearch ('')
	setQuestionSuggestionSelectedId (null)
	resetExtraQuestionState ()
	setReviewCorrectPostId (post.id)
	setSelectingCorrectPost (false)
	setSearch ('')
	return
      }

    finishGame (post.id)
  }

  const filteredPosts = posts
      .filter (post => {
	const needle = search.trim ().toLowerCase ()
	if (!(needle))
	  return false
	if (/^\d+$/.test (needle) && post.id === Number (needle))
	  return true

	return [post.title, post.url, ...post.tags.map (tag => tag.name)]
	    .filter ((value): value is string => Boolean (value))
	    .some (value => value.toLowerCase ().includes (needle))
      })
      .sort ((a, b) => {
	const id = Number (search.trim ())
	if (Number.isFinite (id))
	  return Number (b.id === id) - Number (a.id === id)

	return 0
      })
      .slice (0, 20)

  const loadExtraQuestions = async (gameId: number) => {
    extraQuestionAnswersMutation.reset ()
    setExtraQuestionState ('loading')
    setExtraQuestions ([])
    setExtraQuestionAnswers ({ })
    setPhase ('extra_questions')
    const nonce = createGameSeed ()

    try
    {
      const questions = await queryClient.fetchQuery ({
	  queryKey: gekanatorKeys.extraQuestions (gameId, nonce),
	  queryFn: () => fetchGekanatorExtraQuestions (gameId, nonce) })
      setExtraQuestions (questions.slice (0, 2))
      setExtraQuestionState (questions.length > 0 ? 'ready' : 'empty')
    }
    catch
    {
      setExtraQuestionState ('empty')
    }
  }

  const startExtraQuestions = () => {
    if (reviewCorrectPostId == null || saveMutation.isPending)
      return

    saveReviewedResult (gameId => {
      void loadExtraQuestions (gameId)
    })
  }

  const answerExtraQuestion = (
      questionId:  number,
      value:       GekanatorAnswerValue) => {
    setExtraQuestionAnswers ({
	...extraQuestionAnswers,
	[String (questionId)]: value })
  }

  const introDialogue =
      <>私は<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>。質問から投稿を何でも当ててみせるよ。</>

  const winDialogue =
      <>グカカカカwwwww&emsp;<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>

  const loseDialogue =
      <>ぬわーん!&emsp;<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>外しちゃったグカー!!!!!</>

  const resultDialogue = effectiveResultWon ? winDialogue : loseDialogue

  const dialogue = phase === 'learned' ? resultDialogue : introDialogue
  const introLoadingMessage =
      phase === 'intro' ? '投稿を読み込んでいます……' : '前回のグカネータ状態を復元しています……'
  const questionSuggestionTitle =
      questionSuggestionEntryMode === 'search' ? 'まず既存質問を探してください。' : '新しい質問を追加します。'

  const introLoading = isLoading || acceptedQuestionsLoading
  const readyToStart =
      !(introLoading)
      && acceptedQuestionsFetched
      && posts.length > 0
      && !(error)
      && !(acceptedQuestionsError)

  useEffect (() => {
    if (
	phase !== 'question'
	|| currentQuestion
	|| isLoading
	|| acceptedQuestionsLoading
	|| shouldEnterGuessPhase (questionPlan.guessReason)
	|| eligiblePosts.length === 1)
      return

    const recovered = recoverQuestionState ({
	nextAnswers: answers,
	nextAskedIds: askedIds,
	nextAskedQuestionBank: askedQuestionBank,
	nextSoftenedQuestionIds: softenedQuestionIds,
	nextRejectedPostIds: rejectedPostIds,
	nextRecoveredCandidatePosts: recoveredCandidatePosts,
	nextRecoveryStepCount: recoveryStepCount,
	allowPreQuestionRecovery: true })

    if (
	recovered.recoveryStepCount === recoveryStepCount
	&& recovered.recoveredCandidatePosts.size === recoveredCandidatePosts.size
	&& recovered.softenedQuestionIds.size === softenedQuestionIds.size)
      return

    setSoftenedQuestionIds (recovered.softenedQuestionIds)
    setRecoveredCandidatePosts (recovered.recoveredCandidatePosts)
    setRecoveryStepCount (recovered.recoveryStepCount)
    setScores (recovered.scores)
  }, [
      phase,
      currentQuestion,
      questionPlan,
      answers,
      askedIds,
      askedQuestionBank,
      softenedQuestionIds,
      rejectedPostIds,
      recoveredCandidatePosts,
      recoveryStepCount,
      eligiblePosts,
      recoverQuestionState,
      isLoading,
      acceptedQuestionsLoading])

  useEffect (() => {
    if (
	phase !== 'question'
	|| isLoading
	|| acceptedQuestionsLoading)
      return

    if (
	currentQuestion
	|| !(questionPlan.guess)
	|| !(shouldEnterGuessPhase (questionPlan.guessReason)))
      return

    setWinningRunTargetId (questionPlan.winningRunTargetId)
    setWinningRunStartAnswerCount (questionPlan.winningRunStartAnswerCount)
    setActiveGuessId (questionPlan.guess.id)
    setLastGuessQuestionCount (answers.length)
    setGuessReason (questionPlan.guessReason)
    setPhase ('guess')
  }, [
      phase,
      currentQuestion,
      questionPlan,
      answers,
      isLoading,
      acceptedQuestionsLoading])

  return (
      <MainArea className="relative isolate overflow-x-hidden bg-yellow-50 dark:bg-red-975">
	<Helmet>
	  <title>{`グカネータ | ${ SITE_TITLE }`}</title>
	</Helmet>

	<GekanatorBackdrop
	    posts={backgroundPosts}
	    mascotAsset={mascotAsset}
	    phase={phase}
	    displayedGuess={displayedGuess}
	    visualSeed={backgroundVisualSeed}
	    motionMode={effectiveBackgroundMotionMode}
	    winningRunTargetPost={winningRunActive ? winningRunTargetPost : null}
	    winningRunQuestionCount={winningRunQuestionsAsked}/>

	<div className="relative z-10 mx-auto max-w-4xl space-y-6">
	  <header className="flex flex-wrap items-end justify-between gap-3">
	    <div className="space-y-2">
	      <h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
		グカネータ
	      </h1>
	    </div>
	    <div className="flex flex-wrap justify-end gap-2">
	      <div className="rounded-full border border-yellow-300 bg-white/80 px-2 py-1
			      text-xs shadow-sm backdrop-blur dark:border-red-800
			      dark:bg-red-950/75">
		<span className="mr-2 font-bold text-neutral-600 dark:text-neutral-300">
		  背景
		</span>
		{[{ mode: 'off' as const, label: 'オフ' },
		  { mode: 'on' as const, label: 'オン' }]
		 .map (({ mode, label }) => {
		   const modeClass =
		       backgroundMotionMode === mode
		       ? 'bg-pink-600 text-white'
		       : 'text-neutral-600 hover:bg-yellow-100 dark:text-neutral-300 dark:hover:bg-red-900'

		   return (
		       <button
			   key={mode}
			   type="button"
			   className={cn (
			       'rounded-full px-2.5 py-1 transition-colors',
			       modeClass)}
			   onClick={() => setBackgroundMotionMode (mode)}>
			 {label}
		       </button>)
		 })}
		{prefersReducedMotion && effectiveBackgroundMotionMode !== 'off' && (
		    <span className="ml-2 text-[11px] text-neutral-500 dark:text-neutral-400">
		      端末設定により控えめ表示
		    </span>)}
	      </div>
	    </div>
	  </header>

	  <section className="relative z-10 rounded-lg border border-yellow-300
			      bg-white/90 p-4 shadow-sm backdrop-blur-sm
			      dark:border-red-800 dark:bg-red-950/90">
	    <div className="relative z-10 flex gap-4">
	      <div className="shrink-0 space-y-2">
		<div className="overflow-hidden rounded-[1.4rem] border border-white/70
				bg-white/75 shadow-lg backdrop-blur dark:border-red-900/80
				dark:bg-red-950/70">
		  <img
		      src={mascotAsset}
		      alt={mascotAlt}
		      className="h-28 w-28 object-cover md:h-32 md:w-32"/>
		</div>
	      </div>
	      <div className="min-w-0 flex-1 space-y-3">
		{phase === 'intro' && (
		    <p className="text-lg font-bold">
		      {dialogue}
		    </p>)}

		{introLoading && (
		    <p>
		      {introLoadingMessage}
		    </p>)}
		{(Boolean (error) || Boolean (acceptedQuestionsError))
		    && <p>グカネータの質問データを読み込めませんでした．</p>}

		{phase === 'intro' && readyToStart && restorePromptVisible && (
		    <div className="flex flex-wrap gap-2">
		      <button
			  type="button"
			  className="rounded border border-yellow-300 px-4 py-2
				     hover:bg-yellow-100 dark:border-red-700
				     dark:hover:bg-red-900"
			  onClick={() => {
			    reset ()
			    setRestorePromptVisible (false)
			    setPhase ('question')
			  }}>
			最初からやり直す
		      </button>
		      <button
			  type="button"
			  className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				     hover:bg-pink-500"
			  onClick={continueStoredGame}>
			続きから
		      </button>
		    </div>)}

		{phase === 'intro' && readyToStart && !(restorePromptVisible) && (
		    <button
			type="button"
			className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				   hover:bg-pink-500"
			onClick={() => {
			  setRestorePromptVisible (false)
			  setPhase ('question')
			}}>
		      始める
		    </button>)}

		{phase === 'question' && currentQuestion && (
		    <div className="space-y-4">
		      <div>
			<p className="text-sm text-neutral-500">
			  質問 {answers.length + 1}
			</p>
			<p className="text-xl font-bold">{currentQuestion.text}</p>
		      </div>
		      {isAdmin && (
			  <div className="rounded border border-yellow-100 px-3 py-2
					  text-sm dark:border-red-900">
			    <div className="font-bold">現在候補: {eligiblePosts.length} 件</div>
			    <div className="mt-1 text-xs text-neutral-600 dark:text-neutral-300">
			      winningRunTargetId: {String (questionPlan.winningRunTargetId)}
			      {' / '}
			      winningRunQuestionCount: {winningRunQuestionsAsked}
			      {' / '}
			      guessReason: {guessReason ?? '-'}
			      {' / '}
			      questionMode: {questionPlan.questionMode ?? '-'}
			      {' / '}
			      recoveryStepCount: {recoveryStepCount}
			      {' / '}
			      currentQuestion===null: {String (currentQuestion == null)}
			    </div>
			    {topScoredPosts.length > 0 && (
				<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1">
				  {topScoredPosts.map (item => (
				      <span key={item.post.id}>
					#{item.post.id}: score {item.score.toFixed (1)}
				      </span>))}
				</div>)}
			  </div>)}
		      {isAdmin && answerPreviews.length > 0 && (
			  <div className="grid gap-2 text-sm md:grid-cols-2">
			    {answerOptions.map (option => {
			      const preview =
				  answerPreviews.find (item => item.answer === option.value)
			      return (
				  <div
				      key={option.value}
				      className="rounded border border-yellow-100 px-3 py-2
						 dark:border-red-900">
				    <span className="font-bold">{option.label}</span>
				    {' '}
				    <span className="text-neutral-600 dark:text-neutral-300">
				      候補 {preview ? preview.candidateCount : 0} 件
				    </span>
				    <div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
				      effective {preview?.effectiveCandidates.toFixed (2) ?? '0.00'}
				      {' / '}
				      entropy {preview?.entropy.toFixed (2) ?? '0.00'}
				    </div>
				  </div>)
			    })}
			  </div>)}
		      <div className="flex flex-wrap gap-2">
			{answerOptions.map (option => (
			    <button
				key={option.value}
				type="button"
				className="rounded border border-yellow-300 px-3 py-2
					   hover:bg-yellow-100 dark:border-red-700
					   dark:hover:bg-red-900"
				onClick={() => answer (option.value)}>
			      {option.label}
			    </button>))}
			{history.length > 0 && (
			    <button
				type="button"
				className="rounded border border-neutral-300 px-3 py-2
					   hover:bg-neutral-100 dark:border-neutral-700
					   dark:hover:bg-red-900"
				onClick={undoAnswer}>
			      戻る
			    </button>)}
		      </div>
		    </div>)}
		{phase === 'question' && !(currentQuestion) && isAdmin && (
		    <div className="rounded border border-yellow-100 px-3 py-2 text-sm
				    dark:border-red-900">
		      <div className="font-bold">question stalled</div>
		      <div className="mt-1 text-xs text-neutral-600 dark:text-neutral-300">
			winningRunTargetId: {String (questionPlan.winningRunTargetId)}
			{' / '}
			winningRunQuestionCount: {winningRunQuestionsAsked}
			{' / '}
			guessReason: {questionPlan.guessReason ?? '-'}
			{' / '}
			questionMode: {questionPlan.questionMode ?? '-'}
			{' / '}
			recoveryStepCount: {recoveryStepCount}
			{' / '}
			candidateCount: {eligiblePosts.length}
		      </div>
		    </div>)}
		{phase === 'guess' && displayedGuess && (
		    <div className="space-y-4">
		      <p className="text-xl font-bold">思い浮かべているのは、これだね?</p>
		      {isAdmin && (
			  <div className="rounded border border-yellow-100 px-3 py-2
					  text-sm dark:border-red-900">
			    winningRunTargetId: {String (questionPlan.winningRunTargetId)}
			    {' / '}
			    winningRunQuestionCount: {winningRunQuestionsAsked}
			    {' / '}
			    guessReason: {guessReason ?? '-'}
			    {' / '}
			    questionMode: {questionPlan.questionMode ?? '-'}
			    {' / '}
			    recoveryStepCount: {recoveryStepCount}
			    {' / '}
			    currentQuestion===null: {String (currentQuestion == null)}
			  </div>)}
		      <PostMiniCard post={displayedGuess}/>
		      <div className="flex flex-wrap gap-2">
			<button
			    type="button"
			    className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				       hover:bg-pink-500"
			    onClick={() => {
			      if (displayedGuess)
				finishGame (displayedGuess.id)
			    }}>
			  当たり
			</button>
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-4 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900"
			    onClick={rejectGuess}>
			  違う
			</button>
			{history.length > 0 && (
			    <button
				type="button"
				className="rounded border border-neutral-300 px-4 py-2
					   hover:bg-neutral-100 dark:border-neutral-700
					   dark:hover:bg-red-900"
				onClick={undoAnswer}>
			      戻る
			    </button>)}
		      </div>
		      {saveMutation.isError && (
			  <p className="text-sm text-red-600">
			    記録できませんでした。通信状態を確認してもう一度試して。
			  </p>)}
		    </div>)}

		{phase === 'continue' && (
		    <div className="space-y-4">
		      <p className="text-xl font-bold">続けますか?</p>
		      <div className="flex flex-wrap gap-2">
			<button
			    type="button"
			    className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				       hover:bg-pink-500"
			    onClick={continueGame}>
			  はい
			</button>
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-4 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900"
			    onClick={() => setSelectingCorrectPost (true)}>
			  いいえ
			</button>
			{history.length > 0 && (
			    <button
				type="button"
				className="rounded border border-neutral-300 px-4 py-2
					   hover:bg-neutral-100 dark:border-neutral-700
					   dark:hover:bg-red-900"
				onClick={undoAnswer}>
			      戻る
			    </button>)}
		      </div>
		    </div>)}

		{phase === 'end' && (
		    <div className="space-y-4">
		      <div>
			<p className="text-xl font-bold">{resultDialogue}</p>
		      </div>

		      {reviewGuessedPost && (
			  <div className="space-y-2">
			    <div className="font-bold">推測した投稿</div>
			    <PostMiniCard post={reviewGuessedPost}/>
			  </div>)}

		      <div className="space-y-2">
			<div className="font-bold">正解の投稿</div>
			{reviewCorrectPost && <PostMiniCard post={reviewCorrectPost}/>}
			{!(reviewCorrectPost) && (
			    <p className="text-sm text-red-600">正解投稿を選んでください。</p>)}
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-3 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900"
			    onClick={() => setSelectingCorrectPost (true)}>
			  正解投稿を変更
			</button>
		      </div>

		      {reviewGuessedPostId != null && reviewCorrectPostId != null && (
			  <p className="text-sm text-neutral-600 dark:text-neutral-300">
			    判定: {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : 'はずれ'}
			  </p>)}

		      {saveMutation.isError && (
			  <p className="text-sm text-red-600">
			    記録できませんでした。通信状態を確認してもう一度試して。
			  </p>)}
		      {!(canPersistGame) && (
			  <p className="text-sm text-neutral-600 dark:text-neutral-300">
			    未ログインのため今回の結果は保存されません。
			    <PrefetchLink to="/users/settings" className="ml-1 underline">
			      設定
			    </PrefetchLink>
			    から引継ぎコードを復元すると、質問追加や追加学習も使へます。
			  </p>)}

		      <div className="flex flex-wrap gap-2">
			<button
			    type="button"
			    className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				       hover:bg-pink-500 disabled:opacity-50"
			    disabled={reviewCorrectPostId == null || saveMutation.isPending}
			    onClick={saveAndReset}>
			  もう一度
			</button>
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-4 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900 disabled:opacity-50"
			    disabled={
			      !(canPersistGame)
			      || reviewCorrectPostId == null
			      || saveMutation.isPending
			    }
			    onClick={startReview}>
			  修正
			</button>
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-4 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900 disabled:opacity-50"
			    disabled={!(canPersistGame)
				      || saveMutation.isPending
				      || questionSuggestionMutation.isPending}
			    onClick={() => setPhase ('question_suggestion')}>
			  質問を追加
			</button>
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-4 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900 disabled:opacity-50"
			    disabled={!(canPersistGame)
				      || reviewCorrectPostId == null
				      || saveMutation.isPending
				      || extraQuestionState === 'loading'
				      || extraQuestionAnswersMutation.isPending}
			    onClick={startExtraQuestions}>
			  追加で質問に答える
			</button>
		      </div>
		    </div>)}

		{phase === 'review' && (
		    <div className="space-y-4">
		      <div>
			<p className="text-xl font-bold">結果修正</p>
		      </div>

		      {reviewGuessedPost && (
			  <div className="space-y-2">
			    <div className="font-bold">推測した投稿</div>
			    <PostMiniCard post={reviewGuessedPost}/>
			  </div>)}

		      <div className="space-y-2">
			<div className="font-bold">正解の投稿</div>
			{reviewCorrectPost && <PostMiniCard post={reviewCorrectPost}/>}
			{!(reviewCorrectPost) && (
			    <p className="text-sm text-red-600">正解投稿を選んでください。</p>)}
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-3 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900"
			    onClick={() => setSelectingCorrectPost (true)}>
			  正解投稿を変更
			</button>
		      </div>

		      <div className="space-y-2">
			<div className="font-bold">質問と回答</div>
			<div className="space-y-2">
			  {answers.map ((answer, index) => {
			    const expectedAnswer = expectedAnswerFor (
				scoringQuestionById.get (answer.questionId),
				reviewCorrectPost)

			    return (
				<div
				    key={`${ answer.questionId }:${ index }`}
				    className="rounded border border-yellow-100 p-3
					       dark:border-red-900">
				  <div className="text-sm text-neutral-600 dark:text-neutral-300">
				    質問 {index + 1}
				  </div>
				  <div className="font-bold">{answer.questionText}</div>
				  <div className="mt-2 grid gap-1 text-sm md:grid-cols-3">
				    <div>
				      <span className="text-neutral-500">グカネータ判定: </span>
				      {expectedAnswer ? answerLabelFor (expectedAnswer) : '不明'}
				    </div>
				    <div>
				      <span className="text-neutral-500">実際の回答: </span>
				      {answerLabelFor (answer.originalAnswer)}
				    </div>
				    <label className="block">
				      <span className="text-neutral-500">修正後: </span>
				      <select
					  value={answer.answer}
					  className="rounded border border-yellow-300 bg-white px-2
						     py-1
						     dark:border-red-700 dark:bg-red-950"
					  onChange={ev =>
					    correctAnswerAt (
						index,
						ev.target.value as GekanatorAnswerValue)}>
					{answerOptions.map (option => (
					    <option key={option.value} value={option.value}>
					      {option.label}
					    </option>))}
				      </select>
				    </label>
				  </div>
				</div>)
			  })}
			</div>
		      </div>

		      {reviewGuessedPostId != null && reviewCorrectPostId != null && (
			  <p className="text-sm text-neutral-600 dark:text-neutral-300">
			    判定:
			    {reviewGuessedPostId === reviewCorrectPostId ? '当たり' : 'はずれ'}
			  </p>)}

		      {saveMutation.isError && (
			  <p className="text-sm text-red-600">
			    記録できませんでした。通信状態を確認してもう一度試して。
			  </p>)}

		      <div className="flex flex-wrap gap-2">
			<button
			    type="button"
			    className="rounded border border-neutral-300 px-4 py-2
				       hover:bg-neutral-100 dark:border-neutral-700
				       dark:hover:bg-red-900"
			    onClick={() => setPhase ('end')}>
			  戻る
			</button>
			<button
			    type="button"
			    className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				       hover:bg-pink-500 disabled:opacity-50"
			    disabled={
			      !(canPersistGame)
			      || reviewCorrectPostId == null
			      || saveMutation.isPending
			      || questionSuggestionMutation.isPending
			    }
			    onClick={saveAndLearn}>
			  完了
			</button>
		      </div>
		    </div>)}

		{phase === 'question_suggestion' && (
		    <div className="space-y-4">
		      <div>
			<p className="text-sm text-neutral-500">質問追加</p>
			<p className="text-xl font-bold">{questionSuggestionTitle}</p>
		      </div>
		      {questionSuggestionEntryMode === 'search' && (
			  <>
			    <label className="block space-y-2">
			      <span className="font-bold">既存質問を検索</span>
			      <input
				  value={questionSuggestionSearch}
				  onChange={ev => {
				    setQuestionSuggestionSearch (ev.target.value)
				    setQuestionSuggestionSelectedId (null)
				  }}
				  className="w-full rounded border border-yellow-300 bg-white px-3 py-2
					     dark:border-red-700 dark:bg-red-950"
				  placeholder="バーカ?wwwwwwwwwwwww"/>
			    </label>
			    {searchableSuggestedQuestions.length > 0 && (
				<>
				  <div className="space-y-2">
				    <div className="font-bold">既存質問候補</div>
				    <div className="max-h-64 space-y-2 overflow-y-auto">
				      {searchableSuggestedQuestions.map (question => {
					const questionClass =
					    questionSuggestionSelectedId === (question.recordId ?? null)
					    ? 'border-pink-600 bg-pink-50 dark:bg-red-900/50'
					    : 'border-yellow-200 hover:bg-yellow-100 dark:border-red-800 dark:hover:bg-red-900'

					return (
					    <button
						key={question.id}
						type="button"
						className={cn (
						    'block w-full rounded border px-3 py-2 text-left',
						    questionClass)}
						onClick={() => {
						  setQuestionSuggestionSelectedId (question.recordId ?? null)
						  setQuestionSuggestion ('')
						}}>
					      <div className="font-bold">{question.text}</div>
					    </button>)
				      })}
				    </div>
				  </div>
				  {selectedSuggestedQuestion && (
				      <p className="text-sm text-neutral-600 dark:text-neutral-300">
					既存質問を選択中: {selectedSuggestedQuestion.text}
				      </p>)}
				</>)}
			  </>)}
		      {questionSuggestionEntryMode === 'new' && (
			  <div className="space-y-2">
			    <div className="font-bold">新規質問</div>
			    <textarea
				value={questionSuggestion}
				onChange={ev => {
				  setQuestionSuggestion (ev.target.value)
				  if (ev.target.value.trim () !== '')
				    setQuestionSuggestionSelectedId (null)
				}}
				className="min-h-24 w-full rounded border border-yellow-300
					   bg-white px-3 py-2 dark:border-red-700
					   dark:bg-red-950"
				placeholder="おっと、彼は逃げている?"/>
			  </div>)}
		      {(canSubmitQuestionSuggestion
			&& !(saveMutation.isPending)
			&& !(questionSuggestionMutation.isPending))
		       && (
			  <label className="block space-y-2">
			    <span className="font-bold">この正解投稿に対する答え</span>
			    <select
				value={questionSuggestionAnswer}
				className="rounded border border-yellow-300 bg-white px-2 py-1
					   dark:border-red-700 dark:bg-red-950"
				onChange={ev =>
				  setQuestionSuggestionAnswer (
				      ev.target.value as GekanatorAnswerValue)}>
			      {answerOptions.map (option => (
				  <option key={option.value} value={option.value}>
				    {option.label}
				  </option>))}
			    </select>
			  </label>)}
		      {questionSuggestionEntryMode !== 'new' && (
			  <div className="space-y-2">
			    <p className="text-sm text-neutral-600 dark:text-neutral-300">
			      適切な質問がない場合は新規追加してください。
			    </p>
			    <div className="flex flex-wrap gap-2">
			      <button
				  type="button"
				  className="rounded border border-yellow-300 px-4 py-2
					     hover:bg-yellow-100 dark:border-red-700
					     dark:hover:bg-red-900"
				  onClick={() => {
				    setQuestionSuggestionEntryMode ('new')
				    setQuestionSuggestionSelectedId (null)
				    setQuestionSuggestionSearch ('')
				  }}>
				新規追加
			      </button>
			    </div>
			  </div>)}
		      <div className="flex flex-wrap gap-2">
			<button
			    type="button"
			    className="rounded border border-neutral-300 px-4 py-2
				       hover:bg-neutral-100 dark:border-neutral-700
				       dark:hover:bg-red-900 disabled:opacity-50"
			    disabled={!(canPersistGame)
				      || saveMutation.isPending
				      || questionSuggestionMutation.isPending}
			    onClick={() => {
			      if (questionSuggestionEntryMode === 'new')
				{
				  setQuestionSuggestionEntryMode ('search')
				  setQuestionSuggestion ('')
				}
			      else
				setPhase ('end')
			    }}>
			  戻る
			</button>
			<button
			    type="button"
			    className="rounded border border-yellow-300 px-4 py-2
				       hover:bg-yellow-100 dark:border-red-700
				       dark:hover:bg-red-900 disabled:opacity-50"
			    disabled={!(canSubmitQuestionSuggestion)
				      || saveMutation.isPending
				      || questionSuggestionMutation.isPending}
			    onClick={() => {
			      submitQuestionSuggestion ()
			      setPhase ('end')
			    }}>
			  保存
			</button>
		      </div>
		      {!(canPersistGame) && (
			  <p className="text-sm text-neutral-600 dark:text-neutral-300">
			    未ログインのため質問追加は送信できません。
			  </p>)}
		      {(saveMutation.isError || questionSuggestionMutation.isError) && (
			  <p className="text-sm text-red-600">
			    記録できませんでした。通信状態を確認してもう一度試して。
			  </p>)}
		    </div>)}

		{phase === 'extra_questions' && (
		    <div className="space-y-4">
		      <div>
			<p className="text-sm text-neutral-500">追加学習</p>
			<p className="text-xl font-bold">追加で質問に答えてください。</p>
		      </div>

		      {extraQuestionState === 'loading' && (
			  <p>追加質問を読み込んでいます……</p>)}

		      {extraQuestionState === 'empty' && (
			  <p>追加で学習できる質問はありませんでした。</p>)}

		      {extraQuestionState === 'ready' && (
			  <div className="space-y-3">
			    {extraQuestions.map ((question, index) => (
				<div
				    key={question.id}
				    className="rounded border border-yellow-100 p-3
					       dark:border-red-900">
				  <div className="text-sm text-neutral-600 dark:text-neutral-300">
				    追加質問 {index + 1}
				  </div>
				  <div className="font-bold">{question.text}</div>
				  <div className="mt-3 flex flex-wrap gap-2">
				    {answerOptions.map (option => {
				      const optionClass =
					  extraQuestionAnswers[String (question.id)] === option.value
					  ? 'border-pink-600 bg-pink-600 text-white'
					  : 'border-yellow-300 hover:bg-yellow-100 dark:border-red-700 dark:hover:bg-red-900'

				      return (
					  <button
					      key={option.value}
					      type="button"
					      className={cn (
						  'rounded border px-3 py-2',
						  optionClass)}
					      onClick={() =>
						answerExtraQuestion (question.id, option.value)}>
					    {option.label}
					  </button>)
				    })}
				  </div>
				</div>))}
			  </div>)}

		      {extraQuestionAnswersMutation.isError && (
			  <p className="text-sm text-red-600">
			    学習内容を保存できませんでした。通信状態を確認してもう一度試して。
			  </p>)}

		      <div className="flex flex-wrap gap-2">
			<button
			    type="button"
			    className="rounded border border-neutral-300 px-4 py-2
				       hover:bg-neutral-100 dark:border-neutral-700
				       dark:hover:bg-red-900"
			    disabled={extraQuestionAnswersMutation.isPending}
			    onClick={() => setPhase ('end')}>
			  戻る
			</button>
			<button
			    type="button"
			    className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				       hover:bg-pink-500 disabled:opacity-50"
			    disabled={
			      !(canPersistGame)
			      ||
			      extraQuestionState !== 'ready'
			      || extraQuestionAnswersMutation.isPending
			      || extraQuestions.some (
				  question => !(extraQuestionAnswers[String (question.id)]))
			    }
			    onClick={saveExtraQuestions}>
			  送信
			</button>
		      </div>
		      {!(canPersistGame) && (
			  <p className="text-sm text-neutral-600 dark:text-neutral-300">
			    未ログインのため追加学習は保存されません。
			  </p>)}
		    </div>)}
	      </div>
	    </div>
	  </section>

	  {['guess', 'continue', 'question', 'end', 'review'].includes (phase)
	   && selectingCorrectPost && (
	      <section className="rounded-lg border border-yellow-300 bg-white p-4
				  dark:border-red-800 dark:bg-red-950">
		<label className="block space-y-2">
		  <span className="font-bold">正解の投稿</span>
		  <input
		      value={search}
		      onChange={ev => setSearch (ev.target.value)}
		      className="w-full rounded border border-yellow-300 bg-white px-3 py-2
				 dark:border-red-700 dark:bg-red-950"
		      placeholder="投稿 Id.・タイトル・URL・タグで検索"/>
		</label>
		<div className="mt-4 space-y-3">
		  {filteredPosts.map (post => (
		      <button
			  key={post.id}
			  type="button"
			  className={cn ('block w-full rounded border border-yellow-200 p-3',
					 'text-left hover:bg-yellow-100',
					 'dark:border-red-800 dark:hover:bg-red-900')}
			  onClick={() => selectCorrectPost (post)}>
			<PostMiniCard post={post}/>
		      </button>))}
		  {search.trim () && filteredPosts.length === 0 && '見つかりません．'}
		  {saveMutation.isError && (
		      <p className="text-sm text-red-600">
			記録できませんでした。通信状態を確認してもう一度試して。
		      </p>)}
		</div>
	      </section>)}
	</div>
      </MainArea>)
}


export default GekanatorPage
