import { useCallback, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useBeforeUnload, useLocation, useNavigate } from 'react-router-dom'

import FieldError from '@/components/common/FieldError'
import FormField from '@/components/common/FormField'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import MainArea from '@/components/layout/MainArea'
import TagLink from '@/components/TagLink'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import BehaviourSettingsSection from '@/components/users/BehaviourSettingsSection'
import InheritDialogue from '@/components/users/InheritDialogue'
import KeyboardSettingsSection from '@/components/users/KeyboardSettingsSection'
import UserCodeDialogue from '@/components/users/UserCodeDialogue'
import { CATEGORY_NAMES, CATEGORIES } from '@/consts'
import { SITE_TITLE } from '@/config'
import { apiPut } from '@/lib/api'
import {
    applyClientAppearance,
    applyThemeAppearanceState,
    buildThemeTokens,
    fetchUserSettings,
    fetchUserThemeSlots,
    getClientActiveDarkThemeSlotNo,
    getClientActiveLightThemeSlotNo,
    getClientThemeMode,
    getDefaultUserThemeSlot,
    hasStoredClientThemeSelection,
    normaliseUserThemeSlots,
    resolveDisplayedTheme,
    setCachedUserThemeSlots,
    setClientThemeAppearance,
    upsertUserThemeSlot,
    USER_THEME_SLOT_SELECTIONS,
} from '@/lib/settings'
import { useUnsavedChangesGuard } from '@/lib/useUnsavedChangesGuard'
import { useKeyboardShortcutSettings, useKeyboardShortcuts } from '@/lib/useKeyboardShortcuts'
import { cn, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'

import type { CSSProperties, Dispatch, FC, KeyboardEvent, SetStateAction } from 'react'

import type { Category, Tag, User } from '@/types'
import type {
    BuiltinThemeId,
    ClientThemeMode,
    ClientTheme,
    ThemeTokens,
    ThemeTagColours,
    UserThemeSlotMap,
    UserThemeSlotNo,
    UserThemeSlotSelection,
} from '@/lib/settings'

type Props = {
    user:     User | null
    setUser:  Dispatch<SetStateAction<User | null>> }

type UserFormField = 'name'
type SettingsFormField = 'theme'
type SettingsTab = 'account' | 'behavior' | 'theme' | 'keyboard'
type ThemeTokenKey = Exclude<keyof ThemeTokens, 'tagColours'>

type TabSpec = {
    id:     SettingsTab
    label:  string }

type AccountSectionProps = {
    user:             User
    name:             string
    onNameChange:     (value: string) => void
    onInheritOpen:    () => void
    onUserCodeOpen:   () => void
    onUserSubmit:     () => void
    userBaseErrors:   string[]
    userFieldErrors:  Partial<Record<UserFormField, string[]>> }

type ThemeModeItem = {
    id:     ClientThemeMode
    label:  string }

type ThemeSelectionItem = {
    id:        UserThemeSlotSelection
    label:     string
    baseTheme: BuiltinThemeId
    slotNo:    UserThemeSlotNo }

type ThemeTokenFieldSpec = {
    key:    ThemeTokenKey
    label:  string }

type ThemeTokenGroupSpec = {
    title:   string
    fields:  ThemeTokenFieldSpec[] }

type ThemeSectionProps = {
    draftThemeMode:          ClientThemeMode
    draftActiveLightThemeSlotNo: UserThemeSlotNo
    draftActiveDarkThemeSlotNo: UserThemeSlotNo
    currentDisplayedThemeSlot: UserThemeSlotSelection
    selectedTheme:           ClientTheme
    displayedThemeLabel:     string
    systemThemeLabel:        string
    hasUnsavedChanges:       boolean
    settingsBaseErrors:      string[]
    settingsFieldErrors:     Partial<Record<SettingsFormField, string[]>>
    onSelectThemeMode:       (themeMode: ClientThemeMode) => void
    onSelectLightThemeSlotNo: (slotNo: UserThemeSlotNo) => void
    onSelectDarkThemeSlotNo: (slotNo: UserThemeSlotNo) => void
    onThemeTokenChange:      (key: ThemeTokenKey, value: string) => void
    onTagColourChange:       (category: Category, colour: string) => void
    onResetThemeSlot:        () => void
    onThemeSubmit:           () => void
    onThemeDiscard:          () => void }

type SettingsTabDirtyState = {
    dirty:    boolean
    discard:  () => void | Promise<void> }

type ThemeDraftState = {
    themeMode:              ClientThemeMode
    activeLightThemeSlotNo: UserThemeSlotNo
    activeDarkThemeSlotNo:  UserThemeSlotNo
    themeSlots:             UserThemeSlotMap }

type ThemeSegmentedOption<T extends string | number> = {
    value: T
    label: string }

type ThemeSegmentedControlProps<T extends string | number> = {
    value:     T
    options:   ThemeSegmentedOption<T>[]
    onChange:  (value: T) => void }

const tabs: TabSpec[] = [
    { id: 'account', label: 'アカウント' },
    { id: 'behavior', label: '動作' },
    { id: 'theme', label: 'テーマ' },
    { id: 'keyboard', label: 'キーボード' } ]

const themeModeSelections: ThemeModeItem[] = [
    { id: 'system', label: 'システム設定に従う' },
    { id: 'light', label: 'ライト' },
    { id: 'dark', label: 'ダーク' } ]

const themeSlotNoSelections: ThemeSegmentedOption<UserThemeSlotNo>[] = [
    { value: 1, label: '1' },
    { value: 2, label: '2' },
    { value: 3, label: '3' } ]

const sectionClassName =
    'space-y-4 rounded-xl p-4'

const themeSelections: ThemeSelectionItem[] = [
    { id: 'user:light:1', label: 'ライト 1', baseTheme: 'light', slotNo: 1 },
    { id: 'user:light:2', label: 'ライト 2', baseTheme: 'light', slotNo: 2 },
    { id: 'user:light:3', label: 'ライト 3', baseTheme: 'light', slotNo: 3 },
    { id: 'user:dark:1', label: 'ダーク 1', baseTheme: 'dark', slotNo: 1 },
    { id: 'user:dark:2', label: 'ダーク 2', baseTheme: 'dark', slotNo: 2 },
    { id: 'user:dark:3', label: 'ダーク 3', baseTheme: 'dark', slotNo: 3 },
]

const themeTokenGroups: ThemeTokenGroupSpec[] = [
    { title: '基本',
      fields: [
	  { key: 'background', label: '背景' },
	  { key: 'foreground', label: '文字' },
	  { key: 'link', label: 'リンク' }] }]


const ThemeSegmentedControl = <T extends string | number,> (
    { value, options, onChange }: ThemeSegmentedControlProps<T>,
) => (
    <div
	role="radiogroup"
	className="inline-flex w-fit flex-wrap gap-2 rounded-xl border border-border bg-muted/40 p-1">
      {options.map (option => (
	  <button
	      key={String (option.value)}
	      type="button"
	      role="radio"
	      aria-checked={value === option.value}
	      className={cn (
		  'min-w-0 rounded-lg px-3 py-1.5 text-sm font-medium',
		  'transition-colors focus-visible:outline-none focus-visible:ring-2',
		  'focus-visible:ring-ring focus-visible:ring-offset-2',
		  value === option.value
		  ? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
		  : 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
	      )}
	      onClick={() => onChange (option.value)}>
	    {option.label}
	  </button>))}
    </div>)

const previewTag = (
    id:         number,
    name:       string,
    category:   Category,
    postCount:  number,
    hasWiki:    boolean,
    options?:   { hasDeerjikists?: boolean },
): Tag => ({
    id,
    name,
    category,
    deprecatedAt: null,
    aliases: [],
    parents: [],
    postCount,
    createdAt: '',
    updatedAt: '',
    hasWiki,
    materialId: null,
    hasDeerjikists: options?.hasDeerjikists ?? false,
    children: [] })

const previewTags: Record<Category, Tag> = {
    deerjikist: previewTag (1, 'にじらー', 'deerjikist', 12, true, { hasDeerjikists: true }),
    meme: previewTag (2, 'ミーム', 'meme', 34, true),
    character: previewTag (3, 'キャラクター', 'character', 56, false),
    general: previewTag (4, '一般', 'general', 78, true),
    material: previewTag (5, '素材', 'material', 90, false),
    meta: previewTag (6, 'メタ', 'meta', 21, true),
    nico: previewTag (7, 'nico:sm9', 'nico', 43, false) }

const parseTab = (search: string): SettingsTab => {
  const tab = new URLSearchParams (search).get ('tab')
  return (tabs.some (candidate => candidate.id === tab)
	  ? (tab as SettingsTab)
	  : 'account')
}

const tabButtonId = (tab: SettingsTab): string => `settings-tab-${ tab }`
const tabPanelId = (tab: SettingsTab): string => `settings-panel-${ tab }`

const themePreviewStyle = (tagColours: ThemeTagColours): CSSProperties =>
    Object.fromEntries (
	CATEGORIES.flatMap (category => [
	    [`--tag-colour-${ category }`, tagColours[category]],
	    [`--tag-colour-${ category }-hover`, tagColours[category]]])) as CSSProperties

const themeLabelBySelection = (selection: UserThemeSlotSelection): string =>
    themeSelections.find (item => item.id === selection)?.label ?? 'ライト 1'

const ensureCompleteThemeSlots = (
    themeSlots: UserThemeSlotMap,
): UserThemeSlotMap =>
    USER_THEME_SLOT_SELECTIONS.reduce<UserThemeSlotMap> (
	(complete, selection) => {
	  const [_, baseTheme, slotNo] = selection.split (':')
	  complete[selection] =
	      themeSlots[selection]
	      ?? getDefaultUserThemeSlot (
		  baseTheme as BuiltinThemeId,
		  Number (slotNo) as UserThemeSlotNo,
	      )
	  return complete
	},
	{ ...themeSlots },
    )

const comparableThemeTokensForSelection = (
    themeSlots:  UserThemeSlotMap,
    selection:   UserThemeSlotSelection,
): ThemeTokens => {
  const [_, baseTheme, slotNo] = selection.split (':')
  const fallback =
      getDefaultUserThemeSlot (
	  baseTheme as BuiltinThemeId,
	  Number (slotNo) as UserThemeSlotNo,
      ).tokens

  return {
      ...fallback,
      ...(themeSlots[selection]?.tokens ?? { }),
      tagColours: {
	  ...fallback.tagColours,
	  ...(themeSlots[selection]?.tokens.tagColours ?? { }),
      },
  }
}

const serialiseThemeTokens = (
    themeSlots:  UserThemeSlotMap,
    selection:   UserThemeSlotSelection,
): string =>
    JSON.stringify (comparableThemeTokensForSelection (themeSlots, selection))


const isTopNavThemeTokenKey = (key: ThemeTokenKey): boolean =>
    key.startsWith ('topNav')


const formatHslComponent = (value: number): string =>
    Number.isInteger (value) ? String (value) : value.toFixed (1).replace (/\.0$/, '')


const hslTokenToHex = (token: string): string => {
  const match =
      token.match (
	  /^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/,
      )
  if (match == null)
    return '#000000'

  const hue = Number (match[1]) / 360
  const saturation = Number (match[2]) / 100
  const lightness = Number (match[3]) / 100

  if (saturation === 0)
    {
      const channel = Math.round (lightness * 255)
      const hex = channel.toString (16).padStart (2, '0')
      return `#${ hex }${ hex }${ hex }`
    }

  const hueToRgb = (
      p: number,
      q: number,
      t: number,
  ): number => {
    let adjusted = t

    if (adjusted < 0)
      adjusted += 1
    if (adjusted > 1)
      adjusted -= 1
    if (adjusted < 1 / 6)
      return p + (q - p) * 6 * adjusted
    if (adjusted < 1 / 2)
      return q
    if (adjusted < 2 / 3)
      return p + (q - p) * (2 / 3 - adjusted) * 6
    return p
  }

  const q =
      lightness < .5
      ? lightness * (1 + saturation)
      : lightness + saturation - lightness * saturation
  const p = 2 * lightness - q
  const red = Math.round (hueToRgb (p, q, hue + 1 / 3) * 255)
  const green = Math.round (hueToRgb (p, q, hue) * 255)
  const blue = Math.round (hueToRgb (p, q, hue - 1 / 3) * 255)

  return `#${ [red, green, blue]
      .map (channel => channel.toString (16).padStart (2, '0'))
      .join ('') }`
}


const hexToHslToken = (hex: string): string => {
  const red = parseInt (hex.slice (1, 3), 16) / 255
  const green = parseInt (hex.slice (3, 5), 16) / 255
  const blue = parseInt (hex.slice (5, 7), 16) / 255
  const max = Math.max (red, green, blue)
  const min = Math.min (red, green, blue)
  const lightness = (max + min) / 2

  if (max === min)
    return `0 0% ${ formatHslComponent (lightness * 100) }%`

  const delta = max - min
  const saturation =
      lightness > .5
      ? delta / (2 - max - min)
      : delta / (max + min)

  let hue = 0
  switch (max)
  {
  case red:
    hue = (green - blue) / delta + (green < blue ? 6 : 0)
    break
  case green:
    hue = (blue - red) / delta + 2
    break
  case blue:
    hue = (red - green) / delta + 4
    break
  }

  return [
      formatHslComponent (hue * 60),
      `${ formatHslComponent (saturation * 100) }%`,
      `${ formatHslComponent (lightness * 100) }%`,
  ].join (' ')
}


const themeTokenToHex = (
    key:    ThemeTokenKey,
    value:  string,
): string => {
  if (isTopNavThemeTokenKey (key))
    {
      if (value.startsWith ('#'))
	return value

      const match = value.match (/^hsl\((.+)\)$/)
      return match == null ? '#000000' : hslTokenToHex (match[1])
    }

  return hslTokenToHex (value)
}


const hexToThemeToken = (
    key:    ThemeTokenKey,
    value:  string,
): string =>
    isTopNavThemeTokenKey (key) ? value : hexToHslToken (value)


const AccountSection: FC<AccountSectionProps> = (
    { name,
      onInheritOpen,
      onNameChange,
      onUserCodeOpen,
      onUserSubmit,
      user,
      userBaseErrors,
      userFieldErrors },
) => (
    <section className={sectionClassName}>
      <h2 className="text-xl font-bold">アカウント</h2>
      <FieldError messages={userBaseErrors}/>

      <FormField label="表示名" messages={userFieldErrors.name}>
	{({ describedBy, invalid }) => (
	    <>
	      <input
		  type="text"
		  aria-describedby={describedBy}
		  aria-invalid={invalid}
		  className={inputClass (invalid)}
		  value={name}
		  placeholder="名もなきニジラー"
		  onChange={ev => onNameChange (ev.target.value)}/>
	      {!(user.name) && (
		  <p className="mt-1 text-sm text-red-500">
		    名前が未設定のアカウントは 30 日間アクセスしないと削除されます!!!!
		  </p>)}
	    </>)}
      </FormField>

      <div className="flex flex-wrap gap-2">
	<Button type="button" onClick={onUserSubmit}>
	  表示名を更新
	</Button>
      </div>

      <div className="space-y-2">
	<Label>引継ぎ</Label>
	<div className="flex flex-wrap gap-2">
	  <Button
	      type="button"
	      onClick={onUserCodeOpen}
	      className="bg-gray-600 text-white"
	      disabled={!(user)}>
	    引継ぎコードを表示
	  </Button>
	  <Button
	      type="button"
	      onClick={onInheritOpen}
	      className="bg-red-600 text-white"
	      disabled={!(user)}>
	    ほかのブラウザから引継ぐ
	  </Button>
	</div>
      </div>
    </section>)

const ThemeSection: FC<ThemeSectionProps> = (
    { draftThemeMode,
      draftActiveLightThemeSlotNo,
      draftActiveDarkThemeSlotNo,
      currentDisplayedThemeSlot,
      selectedTheme,
      hasUnsavedChanges,
      settingsBaseErrors,
      settingsFieldErrors,
      onSelectThemeMode,
      onSelectLightThemeSlotNo,
      onSelectDarkThemeSlotNo,
      onThemeTokenChange,
      onTagColourChange,
      onResetThemeSlot,
      onThemeSubmit,
      onThemeDiscard },
) => {
  return (
      <section className={sectionClassName}>
	<div className="flex flex-wrap items-start justify-between gap-3">
	  <div className="space-y-1">
	    <h2 className="text-xl font-bold">テーマ</h2>
	    {hasUnsavedChanges && (
		<p className="text-sm text-amber-700 dark:text-amber-300">
		  未保存の変更があります
		</p>)}
	  </div>

	  <div className="flex flex-wrap gap-2">
	    <Button
		type="button"
		variant="outline"
		onClick={onResetThemeSlot}>
	      既定に戻す
	    </Button>

	    <Button
		type="button"
		variant="destructive"
		disabled={!(hasUnsavedChanges)}
		onClick={onThemeDiscard}>
	      変更を破棄
	    </Button>
	    <Button
		type="button"
		disabled={!(hasUnsavedChanges)}
		onClick={onThemeSubmit}>
	      {`${ themeLabelBySelection (currentDisplayedThemeSlot) } に保存`}
	    </Button>
	  </div>
	</div>

	<FieldError messages={settingsBaseErrors}/>
	<FieldError messages={settingsFieldErrors.theme ?? []}/>

	<div className="space-y-3 rounded-xl p-4">
	  <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
	    <div className="shrink-0">
	      <Label>表示モード</Label>
	    </div>
	    <ThemeSegmentedControl
		value={draftThemeMode}
		options={themeModeSelections.map (selection => ({
		    value: selection.id,
		    label: selection.label }))}
		onChange={value => onSelectThemeMode (value as ClientThemeMode)}/>
	  </div>

	  <div className="space-y-3">
	    <div className="grid gap-3 md:grid-cols-2">
	      <div className="space-y-2">
		<Label>ライト時</Label>
		<select
		    className={cn (inputClass (false), 'w-full')}
		    value={draftActiveLightThemeSlotNo}
		    onChange={ev =>
			onSelectLightThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
		  {themeSlotNoSelections.map (selection => (
			<option key={selection.value} value={selection.value}>
			  {`ライト ${ selection.value }`}
			</option>))}
		</select>
	      </div>

	      <div className="space-y-2">
		<Label>ダーク時</Label>
		<select
		    className={cn (inputClass (false), 'w-full')}
		    value={draftActiveDarkThemeSlotNo}
		    onChange={ev =>
			onSelectDarkThemeSlotNo (Number (ev.target.value) as UserThemeSlotNo)}>
		  {themeSlotNoSelections.map (selection => (
			<option key={selection.value} value={selection.value}>
			  {`ダーク ${ selection.value }`}
			</option>))}
		</select>
	      </div>
	    </div>
	  </div>
	</div>

	<div className="space-y-4">
	  {themeTokenGroups.map (group => (
	      <div
		  key={group.title}
		  className="space-y-3 rounded-xl p-4">
		<h3 className="text-sm font-semibold">{group.title}</h3>
		<div className="space-y-1">
		  {group.fields.map (field => (
		      <div
			  key={field.key}
			  className="flex items-center justify-between gap-4 border-b border-border/60 py-2
				     last:border-b-0">
			<p className="text-sm font-medium">{field.label}</p>
			<div className="flex items-center gap-3">
			  <input
			      type="color"
			      value={themeTokenToHex (field.key, selectedTheme.tokens[field.key])}
			      onChange={ev =>
				  onThemeTokenChange (field.key, hexToThemeToken (field.key, ev.target.value))}
			      className="h-9 w-14 cursor-pointer rounded border border-border
					 bg-transparent p-1 disabled:cursor-not-allowed
					 disabled:opacity-50"/>
			</div>
		      </div>))}
		</div>
	      </div>))}

	  <div className="space-y-3 rounded-xl bg-background/70 p-4">
	    <div className="flex flex-wrap items-center justify-between gap-2">
	      <div>
		<h3 className="font-medium">タグ</h3>
	      </div>
	    </div>

	    <div className="space-y-1">
	      {CATEGORIES.map (category => (
		  <div
		      key={category}
		      className="flex items-center justify-between gap-4 border-b border-border/60 py-2
				 last:border-b-0">
		    <div className="flex min-w-0 items-center gap-3">
		      <p className="text-sm font-medium">{CATEGORY_NAMES[category]}</p>
		      <div style={themePreviewStyle (selectedTheme.tokens.tagColours)}>
			<TagLink
			    tag={previewTags[category]}
			    linkFlg={false}
			    truncateOnMobile
			    withWiki={false}/>
		      </div>
		    </div>

		    <div className="flex items-center gap-3">
		      <input
			  type="color"
			  value={selectedTheme.tokens.tagColours[category]}
			  onChange={ev => onTagColourChange (category, ev.target.value)}
			  className="h-9 w-14 cursor-pointer rounded border border-border
				     bg-transparent p-1 disabled:cursor-not-allowed
				     disabled:opacity-50"/>
		    </div>
		  </div>))}
	    </div>
	  </div>
	</div>
      </section>)
}


const SettingPage: FC<Props> = ({ user, setUser }) => {
  const location = useLocation ()
  const navigate = useNavigate ()
  const dialogue = useDialogue ()
  const { registerUnsavedChangesSource } = useUnsavedChangesGuard ()

  const [inheritVsbl, setInheritVsbl] = useState (false)
  const [loading, setLoading] = useState (true)
  const [name, setName] = useState ('')
  const [settingsError, setSettingsError] = useState<string | null> (null)
  const [savedThemeMode, setSavedThemeMode] =
      useState<ClientThemeMode> (getClientThemeMode ())
  const [draftThemeMode, setDraftThemeMode] =
      useState<ClientThemeMode> (getClientThemeMode ())
  const [savedActiveLightThemeSlotNo, setSavedActiveLightThemeSlotNo] =
      useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
  const [draftActiveLightThemeSlotNo, setDraftActiveLightThemeSlotNo] =
      useState<UserThemeSlotNo> (getClientActiveLightThemeSlotNo ())
  const [savedActiveDarkThemeSlotNo, setSavedActiveDarkThemeSlotNo] =
      useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
  const [draftActiveDarkThemeSlotNo, setDraftActiveDarkThemeSlotNo] =
      useState<UserThemeSlotNo> (getClientActiveDarkThemeSlotNo ())
  const [savedThemeSlots, setSavedThemeSlots] =
      useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
  const [draftThemeSlots, setDraftThemeSlots] =
      useState<UserThemeSlotMap> (() => ensureCompleteThemeSlots ({ }))
  const [dirtyStates, setDirtyStates] =
      useState<Partial<Record<SettingsTab, SettingsTabDirtyState>>> ({ })
  const [userCodeVsbl, setUserCodeVsbl] = useState (false)
  const { conflictActionIds } = useKeyboardShortcutSettings ()
  const { baseErrors: userBaseErrors,
	  fieldErrors: userFieldErrors,
	  clearValidationErrors: clearUserValidationErrors,
	  applyValidationError: applyUserValidationError } = useValidationErrors<UserFormField> ()
  const { baseErrors: settingsBaseErrors,
	  fieldErrors: settingsFieldErrors,
	  clearValidationErrors: clearSettingsValidationErrors,
	  applyValidationError: applySettingsValidationError } =
      useValidationErrors<SettingsFormField> ()

  const activeTab = useMemo<SettingsTab> (() => parseTab (location.search), [location.search])
  const displayedThemeSelection = useMemo (
      () => resolveDisplayedTheme ({
	  themeMode: draftThemeMode,
	  activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
	  activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots),
      [draftActiveDarkThemeSlotNo,
       draftActiveLightThemeSlotNo,
       draftThemeMode,
       draftThemeSlots])
  const selectedTheme = useMemo<ClientTheme> (
      () => {
	const currentSelection =
	    themeSelections.find (
		selection => selection.id === displayedThemeSelection.selection,
	    )
	    ?? themeSelections[0]
	const themeSlot =
	    draftThemeSlots[currentSelection.id]
	    ?? getDefaultUserThemeSlot (currentSelection.baseTheme, currentSelection.slotNo)
	return {
	    id: currentSelection.baseTheme,
	    name: themeLabelBySelection (currentSelection.id),
	    builtin: false,
	    baseTheme: currentSelection.baseTheme,
	    tokens: themeSlot.tokens }
      },
      [displayedThemeSelection.selection, draftThemeSlots])
  const hasUnsavedThemeChanges = useMemo (
      () =>
	  serialiseThemeTokens (draftThemeSlots, displayedThemeSelection.selection)
	  !== serialiseThemeTokens (savedThemeSlots, displayedThemeSelection.selection),
      [displayedThemeSelection.selection, draftThemeSlots, savedThemeSlots])
  const savedName = user?.name ?? ''
  const hasUnsavedAccountChanges = name !== savedName
  const hasPageUnsavedChanges = Object.values (dirtyStates).some (state => state.dirty)

  const applyActiveTab = (tab: SettingsTab) => {
    const params = new URLSearchParams (location.search)
    params.set ('tab', tab)
    navigate (
	`${ location.pathname }?${ params.toString () }`,
	{ replace: true })
  }

  const updateTabDirtyState = (
      tab:    SettingsTab,
      state:  SettingsTabDirtyState,
  ) => {
    setDirtyStates (current => {
      if (!(state.dirty) && current[tab] == null)
	return current

      const next = { ...current }

      if (!(state.dirty))
	{
	  delete next[tab]
	  return next
	}

      next[tab] = state
      return next
    })
  }

  const confirmDiscardChanges = async (
      description: string,
      cancelText:  string,
      confirmText: string,
  ): Promise<boolean> =>
      dialogue.confirm ({
	  title: '未保存の変更があります',
	  description,
	  confirmText,
	  cancelText,
	  variant: 'danger' })

  const settingsTabErrors = useMemo<Record<SettingsTab, boolean>> (
      () => (
	  { account: Boolean (userFieldErrors.name?.length),
	    behavior: false,
	    theme: Boolean (settingsFieldErrors.theme?.length),
	    keyboard: conflictActionIds.size > 0 }),
      [conflictActionIds.size, settingsFieldErrors.theme, userFieldErrors.name])

  const applyDraftThemeState = (
      { themeMode = draftThemeMode,
	 activeLightThemeSlotNo = draftActiveLightThemeSlotNo,
	 activeDarkThemeSlotNo = draftActiveDarkThemeSlotNo,
	 themeSlots = draftThemeSlots,
	 }: { themeMode?:               ClientThemeMode
	      activeLightThemeSlotNo?:  UserThemeSlotNo
	      activeDarkThemeSlotNo?:   UserThemeSlotNo
	      themeSlots?:              UserThemeSlotMap },
  ) => {
    setDraftThemeMode (themeMode)
    setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
    setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
    setDraftThemeSlots (themeSlots)
    setCachedUserThemeSlots (themeSlots)
    applyThemeAppearanceState ({
	themeMode,
	activeLightThemeSlotNo,
	activeDarkThemeSlotNo }, themeSlots)
  }

  const discardAccountChanges = () => {
    setName (savedName)
    clearUserValidationErrors ()
  }

  const currentThemeDraftState = (): ThemeDraftState => ({
      themeMode: draftThemeMode,
      activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
      activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
      themeSlots: draftThemeSlots })

  const savedThemeDraftState = (): ThemeDraftState => ({
      themeMode: savedThemeMode,
      activeLightThemeSlotNo: savedActiveLightThemeSlotNo,
      activeDarkThemeSlotNo: savedActiveDarkThemeSlotNo,
      themeSlots: savedThemeSlots })

  const discardThemeChanges = () => {
    const selection = displayedThemeSelection.selection
    const [_, baseTheme, slotNo] = selection.split (':')
    const nextThemeSlots = {
	...draftThemeSlots,
	[selection]: savedThemeSlots[selection]
	    ?? getDefaultUserThemeSlot (
		baseTheme as BuiltinThemeId,
		Number (slotNo) as UserThemeSlotNo,
	    ),
    }

    setDraftThemeSlots (nextThemeSlots)
    setCachedUserThemeSlots (nextThemeSlots)
    applyThemeAppearanceState ({
	themeMode: draftThemeMode,
	activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
	activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo,
    }, nextThemeSlots)
  }

  const discardAllDirtyChanges = useCallback (async (): Promise<void> => {
    const currentDirtyStates = Object.values (dirtyStates)

    for (const dirtyState of currentDirtyStates)
      {
	if (dirtyState?.dirty)
	  await dirtyState.discard ()
      }

    setDirtyStates ({ })
  }, [dirtyStates])

  const discardCurrentTabChanges = async (): Promise<void> => {
    const currentDirtyState = dirtyStates[activeTab]

    if (currentDirtyState?.dirty)
      await currentDirtyState.discard ()

    setDirtyStates ({ })
  }

  const requestActiveTab = async (
      nextTab: SettingsTab,
  ): Promise<void> => {
    if (nextTab === activeTab)
      return

    const currentDirtyState = dirtyStates[activeTab]
    if (!(currentDirtyState?.dirty))
      {
	applyActiveTab (nextTab)
	return
      }

    const confirmed = await confirmDiscardChanges (
	'このタブを離れると、保存していない変更は失われます。',
	'このタブに残る',
	'変更を破棄して移動')
    if (!(confirmed))
      return

    await discardCurrentTabChanges ()
    applyActiveTab (nextTab)
  }

  const requestThemeStateChange = async (
      buildNextState: (baseState: ThemeDraftState) => ThemeDraftState,
      description = 'このまま切り替えると、保存していない変更は失われます。',
      cancelText = 'このまま編集する',
      confirmText = '変更を破棄して切り替える',
  ): Promise<void> => {
    const baseState = currentThemeDraftState ()
    let themeSlots = baseState.themeSlots

    if (hasUnsavedThemeChanges)
      {
	const confirmed = await confirmDiscardChanges (
	    description,
	    cancelText,
	    confirmText)
	if (!(confirmed))
	  return

	const selection = displayedThemeSelection.selection
	const [_, baseTheme, slotNo] = selection.split (':')
	themeSlots = {
	    ...baseState.themeSlots,
	    [selection]: savedThemeSlots[selection]
		?? getDefaultUserThemeSlot (
		    baseTheme as BuiltinThemeId,
		    Number (slotNo) as UserThemeSlotNo,
		),
	}
      }

    const nextState = buildNextState (
	hasUnsavedThemeChanges
	? { ...savedThemeDraftState (), themeSlots }
	: { ...baseState, themeSlots })

    setSavedThemeMode (nextState.themeMode)
    setSavedActiveLightThemeSlotNo (nextState.activeLightThemeSlotNo)
    setSavedActiveDarkThemeSlotNo (nextState.activeDarkThemeSlotNo)
    setClientThemeAppearance ({
	themeMode: nextState.themeMode,
	activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
	activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
    })
    applyDraftThemeState ({
	themeMode: nextState.themeMode,
	activeLightThemeSlotNo: nextState.activeLightThemeSlotNo,
	activeDarkThemeSlotNo: nextState.activeDarkThemeSlotNo,
	themeSlots: nextState.themeSlots })
  }

  const requestThemeMode = async (
      themeMode: ClientThemeMode,
  ): Promise<void> => {
    if (themeMode === draftThemeMode)
      return

    await requestThemeStateChange (baseState => ({ ...baseState, themeMode }))
  }

  const requestLightThemeSlotNo = async (
      slotNo: UserThemeSlotNo,
  ): Promise<void> => {
    if (slotNo === draftActiveLightThemeSlotNo)
      return

    await requestThemeStateChange (baseState => ({
	...baseState,
	activeLightThemeSlotNo: slotNo }))
  }

  const requestDarkThemeSlotNo = async (
      slotNo: UserThemeSlotNo,
  ): Promise<void> => {
    if (slotNo === draftActiveDarkThemeSlotNo)
      return

    await requestThemeStateChange (baseState => ({
	...baseState,
	activeDarkThemeSlotNo: slotNo }))
  }

  useBeforeUnload (event => {
    if (!(hasPageUnsavedChanges))
      return

    event.preventDefault ()
    event.returnValue = ''
  }, { capture: true })

  const updateDraftThemeSlot = (
      updater:   (tokens: ThemeTokens) => ThemeTokens,
  ) => {
    const selection = displayedThemeSelection.selection
    const fallbackThemeSlot =
	getDefaultUserThemeSlot (
	    displayedThemeSelection.baseTheme,
	    displayedThemeSelection.slotNo,
	)
    const currentThemeSlot =
	draftThemeSlots[selection]
	?? fallbackThemeSlot
    const nextTokens = buildThemeTokens (
	currentThemeSlot.baseTheme,
	updater (currentThemeSlot.tokens),
    )
    const nextThemeSlots = {
	...draftThemeSlots,
	[selection]: { ...currentThemeSlot, tokens: nextTokens } }

    applyDraftThemeState ({ themeSlots: nextThemeSlots })
  }

  const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    const currentIndex = tabs.findIndex (tab => tab.id === activeTab)

    switch (event.key)
    {
    case 'ArrowDown':
    case 'ArrowRight':
      event.preventDefault ()
      requestActiveTab (tabs[(currentIndex + 1) % tabs.length].id)
      break

    case 'ArrowUp':
    case 'ArrowLeft':
      event.preventDefault ()
      requestActiveTab (tabs[(currentIndex - 1 + tabs.length) % tabs.length].id)
      break

    case 'Home':
      event.preventDefault ()
      requestActiveTab (tabs[0].id)
      break

    case 'End':
      event.preventDefault ()
      requestActiveTab (tabs[tabs.length - 1].id)
      break
    }
  }

  const handleUserSubmit = async () => {
    if (!(user))
      return

    clearUserValidationErrors ()

    const formData = new FormData
    formData.append ('name', name)

    try
    {
      const data = await apiPut<User> (
	  `/users/${ user.id }`, formData,
	  { headers: { 'Content-Type': 'multipart/form-data' } })
      setUser (currentUser => ({ ...currentUser, ...data }))
      setName (data.name ?? '')
      toast ({ title: '表示名を更新しました．' })
    }
    catch (submitError)
    {
      applyUserValidationError (submitError)
      toast ({ title: '表示名を更新できませんでした．' })
    }
  }

  const handleThemeSubmit = async () => {
    clearSettingsValidationErrors ()

    try
    {
      const selection = displayedThemeSelection.selection
      let nextSavedThemeSlots = { ...savedThemeSlots }
      const draftThemeSlot = draftThemeSlots[selection]
      const savedThemeSlot = savedThemeSlots[selection]

      if (JSON.stringify (draftThemeSlot?.tokens) !== JSON.stringify (savedThemeSlot?.tokens))
	{
	  const persisted = await upsertUserThemeSlot (
	      draftThemeSlot?.baseTheme ?? selection.split (':')[1] as BuiltinThemeId,
	      draftThemeSlot?.slotNo ?? Number (selection.split (':')[2]) as UserThemeSlotNo,
	      draftThemeSlot?.tokens ?? getDefaultUserThemeSlot (
		  selection.split (':')[1] as BuiltinThemeId,
		  Number (selection.split (':')[2]) as UserThemeSlotNo).tokens)
	  nextSavedThemeSlots = { ...nextSavedThemeSlots, [selection]: persisted }
	}

      const completeThemeSlots = ensureCompleteThemeSlots (nextSavedThemeSlots)

      setSavedThemeSlots (completeThemeSlots)
      setDraftThemeSlots (completeThemeSlots)
      setCachedUserThemeSlots (completeThemeSlots)
      applyClientAppearance (completeThemeSlots)
      toast ({ title: 'テーマ設定を保存しました．' })
    }
    catch (submitError)
    {
      applySettingsValidationError (submitError)
      toast ({ title: 'テーマ設定を保存できませんでした．' })
    }
  }

  const handleThemeDiscard = () => {
    discardThemeChanges ()
  }

  useEffect (() => {
    if (!user)
      return

    setName (user.name ?? '')
  }, [user])

  useEffect (() => {
    if (!(user))
      return

    let cancelled = false

    void (async () => {
      setLoading (true)

      try
      {
	const [settings, themeSlots] = await Promise.all ([fetchUserSettings (),
							   fetchUserThemeSlots ()])
	if (cancelled)
	  return

	const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
	const completeThemeSlots = ensureCompleteThemeSlots (storedThemeSlots)
	const themeMode =
	    hasStoredClientThemeSelection ()
	    ? getClientThemeMode ()
	    : settings.theme
	const activeLightThemeSlotNo = getClientActiveLightThemeSlotNo ()
	const activeDarkThemeSlotNo = getClientActiveDarkThemeSlotNo ()

	setSavedThemeMode (themeMode)
	setDraftThemeMode (themeMode)
	setSavedActiveLightThemeSlotNo (activeLightThemeSlotNo)
	setDraftActiveLightThemeSlotNo (activeLightThemeSlotNo)
	setSavedActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
	setDraftActiveDarkThemeSlotNo (activeDarkThemeSlotNo)
	setSavedThemeSlots (completeThemeSlots)
	setDraftThemeSlots (completeThemeSlots)
	setCachedUserThemeSlots (completeThemeSlots)
	applyThemeAppearanceState ({ themeMode,
				     activeLightThemeSlotNo,
				     activeDarkThemeSlotNo }, completeThemeSlots)
	setSettingsError (null)
      }
      catch
      {
	if (cancelled)
	  return

	setSettingsError ('設定の読込みに失敗しました．')
      }
      finally
      {
	if (!(cancelled))
	  setLoading (false)
      }
    }) ()

    return () => {
      cancelled = true
    }
  }, [user])

  useEffect (() => {
    if (userFieldErrors.name?.length)
      applyActiveTab ('account')
  }, [location.pathname, location.search, userFieldErrors.name])

  useEffect (() => {
    updateTabDirtyState ('account', {
	dirty: hasUnsavedAccountChanges,
	discard: discardAccountChanges })
  }, [hasUnsavedAccountChanges, savedName])

  useEffect (() => {
    if (settingsFieldErrors.theme?.length)
      applyActiveTab ('theme')
  }, [location.pathname, location.search, settingsFieldErrors.theme])

  useEffect (() => {
    applyThemeAppearanceState ({
	themeMode: draftThemeMode,
	activeLightThemeSlotNo: draftActiveLightThemeSlotNo,
	activeDarkThemeSlotNo: draftActiveDarkThemeSlotNo }, draftThemeSlots)

    return () => {
      setCachedUserThemeSlots (savedThemeSlots)
      applyClientAppearance (savedThemeSlots)
    }
  }, [draftActiveDarkThemeSlotNo,
      draftActiveLightThemeSlotNo,
      draftThemeMode,
      draftThemeSlots,
      savedThemeSlots])

  const accountSectionProps: AccountSectionProps | null =
      user
      ? { user,
	  name,
	  onNameChange: setName,
	  onInheritOpen: () => setInheritVsbl (true),
	  onUserCodeOpen: () => setUserCodeVsbl (true),
	  onUserSubmit: handleUserSubmit,
	  userBaseErrors,
	  userFieldErrors }
      : null

  useEffect (() => {
    updateTabDirtyState ('theme', {
	dirty: hasUnsavedThemeChanges,
	discard: handleThemeDiscard })
  }, [hasUnsavedThemeChanges, savedThemeSlots])

  useEffect (() => {
    return registerUnsavedChangesSource ({
        dirty: hasPageUnsavedChanges,
        discard: discardAllDirtyChanges })
  }, [discardAllDirtyChanges, hasPageUnsavedChanges, registerUnsavedChangesSource])

  useKeyboardShortcuts ({
      ['settings.account']: () => {
	requestActiveTab ('account')
      },
      ['settings.behavior']: () => {
	requestActiveTab ('behavior')
      },
      ['settings.theme']: () => {
	requestActiveTab ('theme')
      },
      ['settings.keyboard']: () => {
	requestActiveTab ('keyboard')
      } })

  const themeSectionProps: ThemeSectionProps = {
      draftThemeMode,
      draftActiveLightThemeSlotNo,
      draftActiveDarkThemeSlotNo,
      currentDisplayedThemeSlot: displayedThemeSelection.selection,
      selectedTheme,
      displayedThemeLabel: themeLabelBySelection (displayedThemeSelection.selection),
      systemThemeLabel: displayedThemeSelection.baseTheme === 'dark' ? 'ダーク' : 'ライト',
      hasUnsavedChanges: hasUnsavedThemeChanges,
      settingsBaseErrors,
      settingsFieldErrors,
      onSelectThemeMode: themeMode => {
	requestThemeMode (themeMode)
      },
      onSelectLightThemeSlotNo: slotNo => {
	requestLightThemeSlotNo (slotNo)
      },
      onSelectDarkThemeSlotNo: slotNo => {
	requestDarkThemeSlotNo (slotNo)
      },
      onThemeTokenChange: (key, value) => {
	updateDraftThemeSlot (tokens => ({ ...tokens, [key]: value }))
      },
      onTagColourChange: (category, colour) => {
	updateDraftThemeSlot (tokens => ({
	    ...tokens, tagColours: { ...tokens.tagColours, [category]: colour } }))
      },
      onResetThemeSlot: () => {
	const selection = displayedThemeSelection.selection
	const baseTheme =
	    draftThemeSlots[selection]?.baseTheme
	    ?? selection.split (':')[1] as BuiltinThemeId
	updateDraftThemeSlot (
	    () => getDefaultUserThemeSlot (
		baseTheme,
		(draftThemeSlots[selection]?.slotNo
		 ?? Number (selection.split (':')[2]) as UserThemeSlotNo)).tokens)
      },
      onThemeSubmit: handleThemeSubmit,
      onThemeDiscard: handleThemeDiscard }

  return (
      <MainArea>
	<Helmet>
	  <meta name="robots" content="noindex"/>
	  <title>設定 | {SITE_TITLE}</title>
	</Helmet>

	<div className="mx-auto max-w-xl space-y-4 p-4 md:hidden">
	  <PageTitle>設定</PageTitle>
	  <FieldError messages={settingsError ? [settingsError] : []}/>
	  <div
	      role="tablist"
	      aria-label="設定区分"
	      aria-orientation="horizontal"
	      className="-mx-4 flex gap-2 overflow-x-auto px-4 pb-1">
	    {tabs.map (tab => (
		<button
		    key={tab.id}
		    id={tabButtonId (tab.id)}
		    type="button"
		    role="tab"
		    aria-selected={activeTab === tab.id}
		    aria-controls={tabPanelId (tab.id)}
		    className={cn (
			'shrink-0 rounded-full border px-3 py-2 text-sm font-medium',
			(activeTab === tab.id
			 ? ['border-slate-900 bg-slate-900 text-white',
			    'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
			 : ['border-border bg-background text-foreground']),
		    )}
		    onClick={() => {
		      requestActiveTab (tab.id)
		    }}>
		  {tab.label}
		  {settingsTabErrors[tab.id] ? ' !' : ''}
		</button>))}
	  </div>
	  {loading ? 'Loading...' : (
	      <div
		  id={tabPanelId (activeTab)}
		  role="tabpanel"
		  aria-labelledby={tabButtonId (activeTab)}>
		{accountSectionProps && activeTab === 'account' && (
		    <AccountSection {...accountSectionProps}/>)}
		{activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
		{activeTab === 'keyboard' && (
		    <KeyboardSettingsSection
			sectionClassName={sectionClassName}
			onDirtyStateChange={(dirty, discard) => {
			  updateTabDirtyState ('keyboard', { dirty, discard })
			}}/>)}
		{activeTab === 'behavior' && (
		    <BehaviourSettingsSection
			sectionClassName={sectionClassName}
			onDirtyStateChange={(dirty, discard) => {
			  updateTabDirtyState ('behavior', { dirty, discard })
			}}/>)}
	      </div>)}
	</div>

	<div className="hidden md:flex md:justify-center md:px-4">
	  <div className="grid w-full max-w-[58rem] grid-cols-[12rem_minmax(0,1fr)] gap-6">
	    <div
		role="tablist"
		aria-label="設定区分"
		aria-orientation="vertical"
		className="sticky top-4 flex h-fit flex-col gap-2">
	      {tabs.map (tab => (
		  <button
		      key={tab.id}
		      id={tabButtonId (tab.id)}
		      type="button"
		      role="tab"
		      aria-selected={activeTab === tab.id}
		      aria-controls={tabPanelId (tab.id)}
		      tabIndex={activeTab === tab.id ? 0 : -1}
		      className={cn (
			  'rounded-xl border px-4 py-3 text-left text-sm font-medium',
			  (activeTab === tab.id
			   ? ['border-slate-900 bg-slate-900 text-white',
			      'dark:border-slate-100 dark:bg-slate-100 dark:text-slate-900']
			   : ['border-slate-300 bg-white text-slate-700',
			      'dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100']),
		      )}
		      onClick={() => {
			requestActiveTab (tab.id)
		      }}
		      onKeyDown={handleTabKeyDown}>
		    {tab.label}
		    {settingsTabErrors[tab.id] ? ' ⚠' : ''}
		  </button>))}
	    </div>

	    <div className="w-full max-w-xl justify-self-center space-y-4">
	      <PageTitle>設定</PageTitle>
	      <FieldError messages={settingsError ? [settingsError] : []}/>
	      {loading ? 'Loading...' : (
		  <div
		      id={tabPanelId (activeTab)}
		      role="tabpanel"
		      aria-labelledby={tabButtonId (activeTab)}>
		    {activeTab === 'account' && accountSectionProps && (
			<AccountSection {...accountSectionProps}/>)}
		    {activeTab === 'theme' && <ThemeSection {...themeSectionProps}/>}
		    {activeTab === 'keyboard' && (
			<KeyboardSettingsSection
			    sectionClassName={sectionClassName}
			    onDirtyStateChange={(dirty, discard) => {
			      updateTabDirtyState ('keyboard', { dirty, discard })
			    }}/>)}
		    {activeTab === 'behavior' && (
			<BehaviourSettingsSection
			    sectionClassName={sectionClassName}
			    onDirtyStateChange={(dirty, discard) => {
			      updateTabDirtyState ('behavior', { dirty, discard })
			    }}/>)}
		  </div>)}
	    </div>
	  </div>
	</div>

	<UserCodeDialogue
	    visible={userCodeVsbl}
	    onVisibleChange={setUserCodeVsbl}
	    user={user}
	    setUser={setUser}/>

	<InheritDialogue
	    visible={inheritVsbl}
	    onVisibleChange={setInheritVsbl}
	    setUser={setUser}/>
      </MainArea>)
}


export default SettingPage
