import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, 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 { buildGekanatorQuestions,
	 expectedAnswerForQuestion,
	 fetchGekanatorExtraQuestions,
	 fetchGekanatorQuestions,
	 fetchGekanatorPosts,
	 normalizeTitleLengthCondition,
	 restoreGekanatorQuestion,
	 saveGekanatorExtraQuestionAnswers,
	 saveGekanatorGame,
	 saveGekanatorQuestionSuggestion,
	 storeGekanatorQuestion,
	 titleLengthMinimumForCondition } from '@/lib/gekanator'
import { allConcreteAnswerOptionsExhausted,
	 candidatePostsFor,
	 hardFilteredPostsForAnswer,
	 recoverCandidatePosts } from '@/lib/gekanatorCandidateRecovery'
import { isQuestionHardFilteredAfterAnswers,
	 monthForCondition } from '@/lib/gekanatorQuestionFilters'
import { gekanatorKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'

import type { FC } from 'react'

import type { GekanatorAnswerLog,
	      GekanatorAnswerValue,
	      GekanatorExtraQuestion,
	      GekanatorQuestionCondition,
	      GekanatorQuestion,
	      StoredGekanatorQuestion } from '@/lib/gekanator'
import type { RecoveredCandidatePost } from '@/lib/gekanatorCandidateRecovery'
import type { Post } 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, number>
    recoveryStepCount:       number
    askedQuestionBank:       GekanatorQuestion[]
    search:                  string
    selectingCorrectPost:    boolean
    rejectedPostIds:         Set<number>
    lastGuessQuestionCount:  number
    lastRejectedGuessId:     number | null
    winningRunTargetId:      number | null
    winningRunStartAnswerCount: number | 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
    activeGuessId:               number | null
    reviewGuessedPostId:         number | null
    reviewCorrectPostId:         number | null
    savedGameId:                 number | null
    gameSeed?:                   string
    questionSuggestion:          string
    questionSuggestionAnswer:    GekanatorAnswerValue
    questionSuggestionCount?:    number
    extraQuestions?:             GekanatorExtraQuestion[]
    extraQuestionAnswers?:       Record<string, GekanatorAnswerValue>
    extraQuestionState?:         'idle' | 'loading' | 'ready' | 'empty' | 'saved' }

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 questionsBetweenGuesses = 25
const minQuestionsBeforeCertainGuess = 5
const certainGuessPercent = 99.5
const runnerUpMaxPercent = .5
const hardMaxQuestions = 80
const winningRunQuestionLimit = 3
const softenedAnswerWeight = .35
const confidenceTemperature = 6
const gameStorageKey = 'gekanator:game:v1'
const maxQuestionSuggestionsPerGame = 3

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 => ({
      ...answer,
      questionId: normalizeStoredQuestionId (
	  answer.questionId,
	  answer.questionCondition),
      questionCondition: answer.questionCondition
	  ? normalizeTitleLengthCondition (answer.questionCondition)
	  : undefined })),
  askedIds: game.askedIds.map (questionId => normalizeStoredQuestionId (questionId)),
  softenedQuestionIds: game.softenedQuestionIds.map (questionId =>
      normalizeStoredQuestionId (questionId)),
  recoveredCandidatePosts: game.recoveredCandidatePosts ?? [],
  recoveryStepCount: game.recoveryStepCount ?? 0,
  winningRunTargetId: game.winningRunTargetId ?? null,
  winningRunStartAnswerCount: game.winningRunStartAnswerCount ?? 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 = 2166136261

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

  return hash >>> 0
}


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


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 resettableExtraQuestionState = (): {
    extraQuestions:        GekanatorExtraQuestion[]
    extraQuestionAnswers:  Record<string, GekanatorAnswerValue>
    extraQuestionState:    'idle'
} => ({
  extraQuestions: [],
  extraQuestionAnswers: { },
  extraQuestionState: 'idle' })


const recoveredCandidateMapFromStored = (
    items: RecoveredCandidatePost[],
): Map<number, number> =>
    new Map (items.map (item => [item.postId, item.answerCountAtRecovery]))


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


const deltaFor = (matched: boolean, answer: GekanatorAnswerValue): number => {
  switch (answer)
  {
  case 'yes':
    return matched ? 4 : -4
  case 'no':
    return matched ? -4 : 4
  case 'partial':
    return matched ? 2 : -1
  case 'probably_no':
    return matched ? -2 : 2
  case 'unknown':
    return 0
  }
}


const answerScalarFor = (
    answer:  GekanatorAnswerValue | null,
): number | null => {
  switch (answer)
  {
  case 'yes':
    return 1
  case 'partial':
    return .5
  case 'probably_no':
    return -.5
  case 'no':
    return -1
  case 'unknown':
  case null:
    return null
  }
}


const deltaForExpectedAnswer = (
    expected:  GekanatorAnswerValue | null,
    answer:    GekanatorAnswerValue,
): number => {
  if (answer === 'unknown' || expected === null || expected === 'unknown')
    return 0

  if (expected === 'yes' || expected === 'no')
    return deltaFor (expected === 'yes', answer)

  const expectedScalar = answerScalarFor (expected)
  const answerScalar = answerScalarFor (answer)
  if (expectedScalar === null || answerScalar === null)
    return 0

  const distance = Math.abs (expectedScalar - answerScalar)
  if (distance >= 2)
    return -4
  if (distance >= 1.5)
    return -2
  if (distance >= 1)
    return 0
  if (distance >= .5)
    return 2

  return 4
}


const answerWeightFor = (
    questionId:           string,
    softenedQuestionIds:  Set<string>,
): number => softenedQuestionIds.has (questionId) ? softenedAnswerWeight : 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
}


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

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

    const weight = answerWeightFor (answer.questionId, softenedQuestionIds)
    posts.forEach (post => {
      const expected = expectedAnswerForQuestion (question, post)
      nextScores.set (
	  post.id,
	  (nextScores.get (post.id) ?? 0)
	      + deltaForExpectedAnswer (expected, answer.answer) * weight)
    })
  })

  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,
}: {
    posts:     Post[]
    scores:    Map<number, number>
    question:  GekanatorQuestion
    answer:    GekanatorAnswerValue
}): AnswerPreview => {
  const nextPosts = hardFilteredPostsForAnswer ({
      posts,
      question,
      answer })
  if (nextPosts.length === 0)
    return {
	answer,
	top: null,
	candidateCount: 0,
	effectiveCandidates: 0,
	entropy: 0 }

  const nextScores = new Map (scores)
  nextPosts.forEach (post => {
    const expected = expectedAnswerForQuestion (question, post)
    nextScores.set (
	post.id,
	(nextScores.get (post.id) ?? 0) + deltaForExpectedAnswer (expected, answer))
  })

  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 !== undefined
	      && 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 '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':
      return sum + (
	  sameConditionValue (question.condition, previous)
	      || isMonthCrossMatch (question.condition, previous)
	    ? 40
	    : 0)
    case 'probably_no':
      return sum + (
	  sameConditionValue (question.condition, previous)
	      || isMonthCrossMatch (question.condition, previous)
	    ? 20
	    : 0)
    default:
      return sum
    }
  }, 0)
}


const chooseQuestion = ({
    posts,
    questions,
    scores,
    answers,
    askedIds,
    gameSeed,
}: {
    posts:      Post[]
    questions:  GekanatorQuestion[]
    scores:     Map<number, number>
    answers:    GekanatorAnswerLog[]
    askedIds:   Set<string>
    gameSeed:   string
}): GekanatorQuestion | null => {
  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 signatureFor = (
      question:    GekanatorQuestion,
      candidates:  { post: Post; score: number }[],
  ): string => candidates.map (({ post }) => question.test (post) ? '1' : '0').join ('')

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

  const redundantSignatures = (
      candidates: { post: Post; score: number }[],
  ): Set<string> => {
    const signatures = new Set<string> ()
    questions
	.filter (question => askedIds.has (question.id))
	.forEach (question => {
	  const signature = signatureFor (question, candidates)
	  signatures.add (signature)
	  signatures.add (invertedSignature (signature))
	})

    return signatures
  }

  const rank = (
      questionsToRank:    GekanatorQuestion[],
      candidates:         { post: Post; score: number }[],
      weightedCandidates: { post: Post; score: number; weight: number }[],
  ) => {
    const redundant = redundantSignatures (candidates)
    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 = signatureFor (question, candidates)
	if (redundant.has (signature))
	  return null

	const yes = signature.split ('').filter (value => value === '1').length
	const no = candidates.length - yes
	if (yes === 0 || no === 0)
	  return null

	const yesWeight = weightedCandidates.reduce (
	    (sum, item) => sum + (question.test (item.post) ? item.weight : 0),
	    0)
	const noWeight = 1 - yesWeight
	if (yesWeight <= 0 || noWeight <= 0)
	  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 sourceBonus = sourcePriorityOffset (question)
	const priorityBonus = priorityWeightOffset (question)

	return { question,
		 score: weightedSplitScore * 100
		     + unweightedSplitScore * 8
		     + tagPenalty
		     + narrowPenalty
		     + contradictionPenalty
		     + sourceBonus
		     + priorityBonus,
		 narrow: narrowPenalty > 0 }
      })
      .filter ((item): item is {
	  question:  GekanatorQuestion
	  score:     number
	  narrow:    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 pool = (
      ranked.some (item => !(item.narrow))
	? ranked.filter (item => !(item.narrow))
	: ranked)
      .slice (0, 12)

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

  const bestScore = pool[0]?.score ?? 0
  const weightedPool = pool.map (item => ({
      ...item,
      weight: Math.exp ((bestScore - item.score) / 1.8) }))
  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 item.question
    }

  return weightedPool[weightedPool.length - 1]?.question ?? null
}


const directWinningRunExampleAnswerFor = (
    question:    GekanatorQuestion,
    targetPost:  Post,
): GekanatorAnswerValue | null =>
    question.kind !== 'post_similarity'
      ? null
      : question.exampleAnswers?.[String (targetPost.id) as `${ number }`] ?? null


const winningRunPriorityFor = (
    question:    GekanatorQuestion,
    expected:    GekanatorAnswerValue,
    targetPost:  Post,
): number | null => {
  if (question.kind === 'post_similarity')
    {
	const directAnswer = directWinningRunExampleAnswerFor (question, targetPost)
	if (directAnswer === null)
	  return null
	if (expected === 'yes')
	  return 1
	if (expected === 'no')
	  return 3
	return null
    }

  if (expected === 'yes')
    return 0
  if (expected === 'no')
    return 2

  return null
}


const chooseWinningRunQuestion = ({
    posts,
    fallbackPosts,
    questions,
    targetPost,
    scores,
    answers,
    askedIds,
    gameSeed,
}: {
    posts:      Post[]
    fallbackPosts: Post[]
    questions:  GekanatorQuestion[]
    targetPost: Post
    scores:     Map<number, number>
    answers:    GekanatorAnswerLog[]
    askedIds:   Set<string>
    gameSeed:   string
}): GekanatorQuestion | null => {
  const ranked = questions
      .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 (question, expected, targetPost)
	if (priority === null)
	  return null

	const yesCount = posts.filter (post => question.test (post)).length
	const matchingCount =
	    expected === 'yes' || expected === 'partial'
	      ? yesCount
	      : posts.length - yesCount

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

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

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

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

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

  return chooseQuestion ({
      posts: fallbackPosts.length > 0 ? fallbackPosts : posts,
      questions,
      scores,
      answers,
      askedIds,
      gameSeed })
}


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


const winningRunQuestionCount = (
    answers:                    GekanatorAnswerLog[],
    winningRunStartAnswerCount: number | null,
): number => winningRunStartAnswerCount === null
    ? 0
    : Math.max (0, answers.length - winningRunStartAnswerCount)


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 expectedAnswerFor = (
    question:     GekanatorQuestion | undefined,
    correctPost:  Post | null,
): GekanatorAnswerValue | null =>
    expectedAnswerForQuestion (question, correctPost)


const GekanatorPage: FC = () => {
  const storedGame = useMemo (loadStoredGame, [])
  const queryClient = useQueryClient ()
  const [gameSeed, setGameSeed] = useState (
      storedGame?.gameSeed ?? createGameSeed ())
  const [phase, setPhase] = useState<Phase> (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, number>> (
      () => recoveredCandidateMapFromStored (storedGame?.recoveredCandidatePosts ?? []))
  const [recoveryStepCount, setRecoveryStepCount] = useState (
      storedGame?.recoveryStepCount ?? 0)
  const [askedQuestionBank, setAskedQuestionBank] = useState<GekanatorQuestion[]> (
      () => (storedGame?.askedQuestionBank ?? []).map (restoreGekanatorQuestion))
  const [storedAskedQuestionBankIds, setStoredAskedQuestionBankIds] = useState<string[]> (
      (storedGame?.askedQuestionBank?.length ?? 0) > 0
	? []
	: 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 [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 [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 })

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

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

  useEffect (() => {
    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,
	activeGuessId,
	reviewGuessedPostId,
	reviewCorrectPostId,
	savedGameId,
	gameSeed,
	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,
      activeGuessId,
      reviewGuessedPostId,
      reviewCorrectPostId,
      savedGameId,
      gameSeed,
      questionSuggestion,
      questionSuggestionAnswer,
      questionSuggestionCount,
      extraQuestions,
      extraQuestionAnswers,
      extraQuestionState])

  const eligiblePosts = useMemo (
      () => candidatePostsFor ({
	  posts,
	  questions: askedQuestionBank,
	  answers,
	  softenedQuestionIds,
	  rejectedPostIds,
	  recoveredCandidatePosts }),
      [posts, askedQuestionBank, answers, softenedQuestionIds,
	  rejectedPostIds, recoveredCandidatePosts])
  const questions = useMemo (
      () => mergeQuestions ([
	  ...buildGekanatorQuestions (eligiblePosts.length > 1 ? eligiblePosts : posts),
	  ...acceptedQuestions]),
      [acceptedQuestions, eligiblePosts, posts])
  const scoringQuestions = useMemo (() => {
    return mergeQuestions ([...questions, ...askedQuestionBank])
  }, [questions, askedQuestionBank])
  const scoringQuestionById = useMemo (
      () => new Map (scoringQuestions.map (question => [question.id, question])),
      [scoringQuestions])
  const questionsSinceLastGuess = answers.length - lastGuessQuestionCount
  const availablePosts = useMemo (
      () => posts.filter (post => !(rejectedPostIds.has (post.id))),
      [posts, rejectedPostIds])
  const winningRunTargetPost = useMemo (
      () => winningRunTargetId === null
	  ? null
	  : posts.find (post => post.id === winningRunTargetId) ?? null,
      [posts, winningRunTargetId])
  const winningRunQuestionsAsked = winningRunQuestionCount (
      answers,
      winningRunStartAnswerCount)
  const winningRunActive =
      isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
      && winningRunQuestionsAsked < winningRunQuestionLimit
      && eligiblePosts.length === 1
      && eligiblePosts[0]?.id === winningRunTargetId
      && winningRunTargetPost !== null
  const questionPosts =
      eligiblePosts.length > 1
	|| questionsSinceLastGuess >= minQuestionsBeforeCertainGuess
	  ? eligiblePosts
	  : availablePosts
  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 = winningRunActive && winningRunTargetPost
      ? chooseWinningRunQuestion ({
	  posts,
	  fallbackPosts: availablePosts.length > 1 ? availablePosts : posts,
	  questions: scoringQuestions,
	  targetPost: winningRunTargetPost,
	  scores,
	  answers,
	  askedIds,
	  gameSeed })
      : chooseQuestion ({
	  posts: questionPosts,
	  questions: scoringQuestions,
	  scores,
	  answers,
	  askedIds,
	  gameSeed })
  const answerPreviews = useMemo (
      () => currentQuestion
	  ? answerOptions.map (option => previewAnswer ({
	      posts: eligiblePosts,
	      scores,
	      question: currentQuestion,
	      answer: option.value }))
	  : [],
      [currentQuestion, eligiblePosts, scores])
  const guessablePosts =
      eligiblePosts.length > 0
	? eligiblePosts
	: availablePosts
  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 saveMutation = useMutation ({
      mutationFn: saveGekanatorGame,
      onSuccess: (data, variables) => {
	setSaved (true)
	setSavedGameId (data.id)
	setResultWon (variables.guessedPostId === variables.correctPostId)
      }})
  const questionSuggestionMutation = useMutation ({
      mutationFn: saveGekanatorQuestionSuggestion,
      onSuccess: async data => {
	await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
	setQuestionSuggestionCount (data.count)
	setQuestionSuggestion ('')
	setQuestionSuggestionAnswer ('yes')
      }})
  const extraQuestionAnswersMutation = useMutation ({
      mutationFn: saveGekanatorExtraQuestionAnswers,
      onSuccess: async () => {
	await queryClient.refetchQueries ({ queryKey: gekanatorKeys.questions () })
	setExtraQuestionState ('saved')
	setPhase ('learned')
      }})

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

  const reset = () => {
    clearStoredGame ()
    saveMutation.reset ()
    questionSuggestionMutation.reset ()
    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)
    setActiveGuessId (null)
    setReviewGuessedPostId (null)
    setReviewCorrectPostId (null)
    setSavedGameId (null)
    setGameSeed (createGameSeed ())
    setQuestionSuggestion ('')
    setQuestionSuggestionAnswer ('yes')
    setQuestionSuggestionCount (0)
    resetExtraQuestionState ()
    setHistory ([])
  }

  const recoverQuestionState = ({
      nextAnswers,
      nextAskedIds,
      nextAskedQuestionBank,
      nextSoftenedQuestionIds,
      nextRejectedPostIds,
      nextRecoveredCandidatePosts,
      nextRecoveryStepCount,
      allowPreQuestionRecovery,
  }: {
      nextAnswers:              GekanatorAnswerLog[]
      nextAskedIds:             Set<string>
      nextAskedQuestionBank:    GekanatorQuestion[]
      nextSoftenedQuestionIds:  Set<string>
      nextRejectedPostIds:      Set<number>
      nextRecoveredCandidatePosts: Map<number, number>
      nextRecoveryStepCount:    number
      allowPreQuestionRecovery?: boolean
  }) => {
    let recoveredSoftenedQuestionIds = new Set (nextSoftenedQuestionIds)
    let recoveredCandidatePosts = new Map (nextRecoveredCandidatePosts)
    let recoveredStepCount = nextRecoveryStepCount
    const answerCountAtRecovery =
	allowPreQuestionRecovery
	  ? nextAnswers.length
	  : Math.max (nextAnswers.length - 1, 0)
    let recoveredScores = recalculateScores ({
	posts,
	questions: nextAskedQuestionBank,
	answers: nextAnswers,
	softenedQuestionIds: recoveredSoftenedQuestionIds })
    let recoveredEligiblePosts = candidatePostsFor ({
	posts,
	questions: nextAskedQuestionBank,
	answers: nextAnswers,
	softenedQuestionIds: recoveredSoftenedQuestionIds,
	rejectedPostIds: nextRejectedPostIds,
	recoveredCandidatePosts })
    let recoveredScoringQuestions = mergeQuestions ([
	...buildGekanatorQuestions (
	    recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
	...acceptedQuestions,
	...nextAskedQuestionBank])

    const refreshRecoveredState = () => {
      recoveredScores = recalculateScores ({
	  posts,
	  questions: nextAskedQuestionBank,
	  answers: nextAnswers,
	  softenedQuestionIds: recoveredSoftenedQuestionIds })
      recoveredEligiblePosts = candidatePostsFor ({
	  posts,
	  questions: nextAskedQuestionBank,
	  answers: nextAnswers,
	  softenedQuestionIds: recoveredSoftenedQuestionIds,
	  rejectedPostIds: nextRejectedPostIds,
	  recoveredCandidatePosts })
      recoveredScoringQuestions = mergeQuestions ([
	  ...buildGekanatorQuestions (
	      recoveredEligiblePosts.length > 1 ? recoveredEligiblePosts : posts),
	  ...acceptedQuestions,
	  ...nextAskedQuestionBank])
    }

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

      const nextQuestion = chooseQuestion ({
	  posts: recoveredEligiblePosts,
	  questions: recoveredScoringQuestions,
	  scores: recoveredScores,
	  answers: nextAnswers,
	  askedIds: nextAskedIds,
	  gameSeed })

      return allConcreteAnswerOptionsExhausted (recoveredEligiblePosts, nextQuestion)
    }

    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 (
	    recoveredEligiblePosts.length > 0
	    || nextAnswers.length >= hardMaxQuestions
	)
	  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 }
  }

  const answer = (value: GekanatorAnswerValue) => {
    if (!(currentQuestion))
      {
	setActiveGuessId (guess?.id ?? null)
	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,
	activeGuessId,
	reviewGuessedPostId,
	reviewCorrectPostId }])
    const nextAnswers = [...answers, {
	questionId:      currentQuestion.id,
	questionText:    currentQuestion.text,
	questionCondition: currentQuestion.condition,
	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 nextSoftenedQuestionIds = recovered.softenedQuestionIds
    const nextRecoveredCandidatePosts = recovered.recoveredCandidatePosts
    const nextScores = recovered.scores
    const nextEligiblePosts = recovered.eligiblePosts
    const currentWinningRunActive =
	isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
    const nextWinningRunTargetId =
	nextEligiblePosts.length === 1
	  ? nextEligiblePosts[0]?.id ?? null
	  : null
    const nextWinningRunStartAnswerCount =
	nextWinningRunTargetId === null
	  ? null
	  : currentWinningRunActive
	    && winningRunTargetId === nextWinningRunTargetId
	    && winningRunStartAnswerCount !== null
	      ? winningRunStartAnswerCount
	      : nextAnswers.length

    setScores (nextScores)
    setAskedIds (nextAskedIds)
    setSoftenedQuestionIds (nextSoftenedQuestionIds)
    setRecoveredCandidatePosts (nextRecoveredCandidatePosts)
    setRecoveryStepCount (recovered.recoveryStepCount)
    setAskedQuestionBank (nextAskedQuestionBank)
    setAnswers (nextAnswers)
    setWinningRunTargetId (nextWinningRunTargetId)
    setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount)

    const nextGuessablePosts =
	nextEligiblePosts.length > 0
	  ? nextEligiblePosts
	  : availablePosts
    const nextGuess = bestPost (nextGuessablePosts, nextScores)
    const nextQuestionCount = answers.length + 1
    const nextQuestionsSinceLastGuess =
	nextQuestionCount - lastGuessQuestionCount
    const nextConfidences = confidencesFor (nextGuessablePosts, nextScores)
    const topConfidence = nextConfidences[0] ?? null
    const runnerUpConfidence = nextConfidences[1] ?? null
    const structurallyCertain = nextEligiblePosts.length === 1
    const winningRunContinues =
	nextWinningRunTargetId !== null
	&& nextWinningRunStartAnswerCount !== null
	&& nextEligiblePosts.length === 1
	&& winningRunQuestionCount (
	    nextAnswers,
	    nextWinningRunStartAnswerCount) < winningRunQuestionLimit
    const statisticallyCertain =
	topConfidence !== null
	&& topConfidence.percent >= certainGuessPercent
	&& (runnerUpConfidence === null
	    || runnerUpConfidence.percent <= runnerUpMaxPercent)
    const canGuessByQuestionCount =
	nextQuestionsSinceLastGuess >= questionsBetweenGuesses
    const canGuessEarlyByConfidence =
	nextQuestionsSinceLastGuess >= minQuestionsBeforeCertainGuess
	&& (structurallyCertain || statisticallyCertain)
    const shouldGuess =
	nextQuestionCount >= hardMaxQuestions
	|| (
	    !(winningRunContinues)
	    && (canGuessByQuestionCount || canGuessEarlyByConfidence))
    if (shouldGuess)
      {
	setActiveGuessId (nextGuess?.id ?? null)
	setLastGuessQuestionCount (nextQuestionCount)
	setPhase ('guess')
      }
  }

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

    saveMutation.reset ()
    questionSuggestionMutation.reset ()
    resetExtraQuestionState ()
    setSaved (false)
    setSavedGameId (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)
    setSelectingCorrectPost (false)
    setSearch ('')
    setPhase ('review')
  }

  const saveReviewedResult = (onSuccess: (gameId: number) => void) => {
    if (
	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 = () => {
    saveReviewedResult (reset)
  }

  const saveAndLearn = () => {
    resetExtraQuestionState ()
    saveReviewedResult (() => setPhase ('learned'))
  }

  const restartFromQuestionSuggestion = () => {
    if (savedGameId !== null)
      {
	reset ()
	return
      }

    saveReviewedResult (reset)
  }

  const submitQuestionSuggestion = () => {
    const questionText = questionSuggestion.trim ()
    if (
	!(questionText)
	|| questionSuggestionMutation.isPending
	|| questionSuggestionCount >= maxQuestionSuggestionsPerGame
    )
      return

    saveReviewedResult (gekanatorGameId => {
      questionSuggestionMutation.mutate ({
	  gekanatorGameId,
	  questionText,
	  answer: questionSuggestionAnswer })
    })
  }

  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)
    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)
    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 nextWinningRunTargetId =
	recovered.eligiblePosts.length === 1
	  ? recovered.eligiblePosts[0]?.id ?? null
	  : null
    const nextWinningRunStartAnswerCount =
	nextWinningRunTargetId === null
	  ? null
	  : isWinningRunActive (winningRunTargetId, winningRunStartAnswerCount)
	    && winningRunTargetId === nextWinningRunTargetId
	    && winningRunStartAnswerCount !== null
	      ? winningRunStartAnswerCount
	      : answers.length
    const nextWinningRunTargetPost =
	nextWinningRunTargetId === null
	  ? null
	  : posts.find (post => post.id === nextWinningRunTargetId) ?? null

    const recoveredGuessablePosts =
	recovered.eligiblePosts.length > 0
	  ? recovered.eligiblePosts
	  : availablePosts
    const nextQuestion =
	nextWinningRunTargetPost
	&& nextWinningRunStartAnswerCount !== null
	&& winningRunQuestionCount (
	    answers,
	    nextWinningRunStartAnswerCount) < winningRunQuestionLimit
	  ? chooseWinningRunQuestion ({
	      posts,
	      fallbackPosts: availablePosts.length > 1 ? availablePosts : posts,
	      questions: recovered.scoringQuestions,
	      targetPost: nextWinningRunTargetPost,
	      scores: recovered.scores,
	      answers,
	      askedIds,
	      gameSeed })
	  : chooseQuestion ({
	      posts: recovered.eligiblePosts.length > 1
		  ? recovered.eligiblePosts
		  : availablePosts,
	      questions: recovered.scoringQuestions,
	      scores: recovered.scores,
	      answers,
	      askedIds,
	      gameSeed })

    setWinningRunTargetId (nextWinningRunTargetId)
    setWinningRunStartAnswerCount (nextWinningRunStartAnswerCount)

    if (nextQuestion)
      {
	setPhase ('question')
	return
      }

    setActiveGuessId (bestPost (recoveredGuessablePosts, recovered.scores)?.id ?? null)
    setPhase ('guess')
  }

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

  const selectCorrectPost = (post: Post) => {
    if (phase === 'review')
      {
	setSaved (false)
	setSavedGameId (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)
      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 saveExtraQuestions = () => {
    if (
	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 dialogue =
      phase === 'learned' && resultWon
	? <>グカカカカwwwww  <ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>は何でもお見通し!</>
	: <>私は<ruby>洗澡鹿<rt>シーザオグカ</rt></ruby>．質問から投稿を何でも当ててみせるよ</>
  const introLoading = isLoading || acceptedQuestionsLoading
  const readyToStart =
      !(introLoading)
      && acceptedQuestionsFetched
      && posts.length > 0
      && !(error)
      && !(acceptedQuestionsError)

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

    const winningRunFinished =
	winningRunTargetId !== null
	&& winningRunStartAnswerCount !== null
	&& winningRunQuestionCount (answers, winningRunStartAnswerCount) >= winningRunQuestionLimit
	&& eligiblePosts.length === 1
	&& eligiblePosts[0]?.id === winningRunTargetId
    const nextGuess = displayedGuess ?? guess
    if (currentQuestion || (!(winningRunFinished) && !(nextGuess)))
      return

    setActiveGuessId ((winningRunFinished ? guess : nextGuess)?.id ?? null)
    setLastGuessQuestionCount (answers.length)
    setPhase ('guess')
  }, [
      phase,
      currentQuestion,
      guess,
      displayedGuess,
      answers,
      eligiblePosts,
      winningRunTargetId,
      winningRunStartAnswerCount,
      isLoading,
      acceptedQuestionsLoading])

  return (
      <MainArea className="bg-yellow-50 dark:bg-red-975">
	<Helmet>
	  <meta name="robots" content="noindex"/>
	  <title>{`グカネータ | ${ SITE_TITLE }`}</title>
	</Helmet>

	<div className="mx-auto max-w-4xl space-y-6">
	  <header className="space-y-2">
	    <p className="text-sm text-pink-700 dark:text-pink-200">おたのしみ</p>
	    <h1 className="text-3xl font-bold text-pink-700 dark:text-pink-200">
	      グカネータ
	    </h1>
	  </header>

	  <section className="rounded-lg border border-yellow-300 bg-white p-4
			      shadow-sm dark:border-red-800 dark:bg-red-950">
	    <div className="flex gap-4">
	      <div className="grid h-24 w-24 shrink-0 place-items-center rounded-lg
			      bg-yellow-200 text-center text-sm font-bold
			      text-pink-700 dark:bg-red-900 dark:text-pink-100">
		洗澡鹿
	      </div>
	      <div className="min-w-0 flex-1 space-y-3">
		<p className="text-lg font-bold">
		  {dialogue}
		</p>

		{introLoading && (
		    <p>
		      {phase === 'intro'
		       ? '投稿を読み込んでゐます...'
		       : '前回のグカネータ状態を復元してゐます...'}
		    </p>)}
		{(Boolean (error) || Boolean (acceptedQuestionsError))
		    && <p>グカネータの質問データを読み込めませんでした．</p>}

		{phase === 'intro' && readyToStart && (
		    <button
			type="button"
			className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				   hover:bg-pink-500"
			onClick={() => 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>
		      <div className="rounded border border-yellow-100 px-3 py-2
				      text-sm dark:border-red-900">
			<div className="font-bold">現在候補: {eligiblePosts.length} 件</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>
		      {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>)
			    })}
			  </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 === 'guess' && displayedGuess && (
		    <div className="space-y-4">
		      <p className="text-xl font-bold">これを想像してゐたね?</p>
		      <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-sm text-neutral-500">ゲーム終了</p>
			<p className="text-xl font-bold">グカカカカwwwww</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}/>
			 : <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>)}

		      <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={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={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={reviewCorrectPostId === null
				      || saveMutation.isPending
				      || extraQuestionState === 'loading'
				      || extraQuestionAnswersMutation.isPending}
			    onClick={startExtraQuestions}>
			  追加で質問に答へる
			</button>
		      </div>
		    </div>)}

		{phase === 'review' && (
		    <div className="space-y-4">
		      <div>
			<p className="text-sm text-neutral-500">保存前確認</p>
			<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}/>
			 : <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 bg-pink-600 px-4 py-2 font-bold text-white
				       hover:bg-pink-500 disabled:opacity-50"
			    disabled={
			      reviewCorrectPostId === null
			      || saveMutation.isPending
			      || questionSuggestionMutation.isPending
			    }
			    onClick={saveAndLearn}>
			  完了
			</button>
			<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>
		      </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">どんな質問なら見分けられさう?</p>
			<p className="text-sm text-neutral-600 dark:text-neutral-300">
			  追加済み {questionSuggestionCount} / {maxQuestionSuggestionsPerGame}
			</p>
		      </div>
		      <label className="block space-y-2">
			<span className="font-bold">質問候補</span>
			<textarea
			    value={questionSuggestion}
			    onChange={ev => setQuestionSuggestion (ev.target.value)}
			    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"/>
		      </label>
		      <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>
		      <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={saveMutation.isPending
				      || questionSuggestionMutation.isPending}
			    onClick={() => 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={
			      questionSuggestionCount >= maxQuestionSuggestionsPerGame
			      || reviewCorrectPostId === null
			      || questionSuggestion.trim () === ''
			      || saveMutation.isPending
			      || questionSuggestionMutation.isPending
			    }
			    onClick={submitQuestionSuggestion}>
			  追加
			</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={saveMutation.isPending
				      || questionSuggestionMutation.isPending}
			    onClick={restartFromQuestionSuggestion}>
			  もう一度
			</button>
		      </div>
		      {questionSuggestionCount >= maxQuestionSuggestionsPerGame && (
			  <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">追加で 2 問まで答へてください。</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 => (
					<button
					    key={option.value}
					    type="button"
					    className={cn (
						'rounded border px-3 py-2',
						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')}
					    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={
			      extraQuestionState !== 'ready'
			      || extraQuestionAnswersMutation.isPending
			      || extraQuestions.some (
				  question => !(extraQuestionAnswers[String (question.id)]))
			    }
			    onClick={saveExtraQuestions}>
			  学習する
			</button>
		      </div>
		    </div>)}

		{phase === 'learned' && (
		    <div className="space-y-3">
		      <p>{extraQuestionState === 'saved' ? '学習しました。' : '覚えたよ．次はもっと見通す．'}</p>
		      <button
			  type="button"
			  className="rounded bg-pink-600 px-4 py-2 font-bold text-white
				     hover:bg-pink-500"
			  onClick={reset}>
			もう一回
		      </button>
		    </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
