import { CATEGORIES } from '@/consts'
import { apiGet, apiPatch, apiPut } from '@/lib/api'
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'

import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
import type { KeyBinding, ShortcutActionId } from '@/lib/keyboardShortcuts'

export type UserSettings = {
    theme:               'system' | 'light' | 'dark'
    autoFetchTitle:      'auto' | 'manual' | 'off'
    autoFetchThumbnail:  'auto' | 'manual' | 'off'
    wikiEditorMode:      'split' | 'write' | 'preview' }

export type ThemeTagColours = Record<Category, string>
export type BuiltinThemeId = 'light' | 'dark'
export type UserThemeSlotNo = 1 | 2 | 3
export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }`
export type ClientThemeMode = 'system' | 'light' | 'dark'
export type ThemeSlotSelection =
    | 'system'
    | `builtin:${ BuiltinThemeId }`
    | UserThemeSlotSelection
export type ThemeId = BuiltinThemeId | `custom:${ string }`
export type ActiveThemeId = ThemeId | 'system'
export type ResolvedTheme = 'light' | 'dark'
export type ThemeTokens = {
    background:             string
    foreground:             string
    card:                   string
    cardForeground:         string
    popover:                string
    popoverForeground:      string
    primary:                string
    primaryForeground:      string
    secondary:              string
    secondaryForeground:    string
    muted:                  string
    mutedForeground:        string
    accent:                 string
    accentForeground:       string
    destructive:            string
    destructiveForeground:  string
    border:                 string
    input:                  string
    ring:                   string
    link:                   string
    topNavRootBackgroundMobile:   string
    topNavRootBackgroundDesktop:  string
    topNavActiveBackground:       string
    topNavSubmenuBackground:      string
    topNavMobileActiveBackground: string
    topNavBrandLink:             string
    topNavMenuLink:              string
    tagColours:                  ThemeTagColours }
type EditableTopNavTokens = {
    rootBackgroundMobile:   string
    rootBackgroundDesktop:  string
    activeBackground:       string
    brandLink:              string }
type ResolvedTopNavTokens = {
    rootBackgroundMobile:   string
    rootBackgroundDesktop:  string
    activeBackground:       string
    submenuBackground:      string
    mobileMenuBackground:   string
    mobileActiveBackground: string
    brandLink:              string
    menuLink:               string }
export type UserThemeSlot = {
    baseTheme:   BuiltinThemeId
    slotNo:      UserThemeSlotNo
    tokens:      ThemeTokens
    createdAt?:  string
    updatedAt?:  string }
export type UserThemeSlotMap =
    Partial<Record<UserThemeSlotSelection, UserThemeSlot>>
export type ResolvedThemeSelection = {
    selection:  ThemeSlotSelection
    baseTheme:  BuiltinThemeId
    builtin:    boolean
    tokens:     ThemeTokens }
export type ResolvedDisplayedTheme = {
    themeMode:  ClientThemeMode
    baseTheme:  BuiltinThemeId
    selection:  UserThemeSlotSelection
    slotNo:     UserThemeSlotNo
    tokens:     ThemeTokens }
export type ClientTheme = {
    id:         ThemeId
    name:       string
    builtin:    boolean
    baseTheme:  BuiltinThemeId
    tokens:     ThemeTokens }
export type CustomClientTheme =
    ClientTheme & { builtin: false }

export type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
export type TheatreTagFlow = 'vertical' | 'horizontal'
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
export type ClientAnimationMode = 'normal' | 'reduced' | 'off'
export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off'
export type ClientLinkPreloadMode = 'off' | 'intent'
export type ClientTagRelationDisplayMode = 'flat' | 'grouped'
export type ClientThumbnailMode = 'normal' | 'light' | 'off'
export type ClientPaneBreakpoint = 'desktop' | 'tablet'
export type ClientListKey = 'postList' | 'postSearch' | 'tagList'
export type ClientListLimit = 20 | 50 | 100

type ClientPaneSettings = {
    widthPxByBreakpoint?: Partial<Record<ClientPaneBreakpoint, number>>
    collapsed?:           boolean }

type ClientListSettings = {
    limit?: ClientListLimit
    order?: string }

export type ClientKeyboardSettings = {
    enabled?:   boolean
    bindings?:  Partial<Record<ShortcutActionId, KeyBinding | null>> }

export type ClientAppearanceSettings = {
    themeMode?:             ClientThemeMode
    activeLightThemeSlotNo?: UserThemeSlotNo
    activeDarkThemeSlotNo?: UserThemeSlotNo
    activeThemeSlot?: ThemeSlotSelection
    activeThemeId?: ActiveThemeId
    customThemes?:  Record<string, CustomClientTheme>
    // Legacy fields kept for migration from the earlier appearance model.
    theme?:         UserSettings['theme']
    tagColours?:    Partial<ThemeTagColours> }

export type ClientBehaviorSettings = {
    animation?:        ClientAnimationMode
    linkPreload?:      ClientLinkPreloadMode
    tagRelationDisplay?: ClientTagRelationDisplayMode
    embedAutoLoad?:    ClientEmbedAutoLoadMode
    thumbnailMode?:    ClientThumbnailMode }

// DB-backed settings. At present only `theme` is surfaced in `/users/settings`.
// The remaining fields are retained for existing backend work and are not wired
// to the common settings UI yet.
export const DEFAULT_USER_SETTINGS: UserSettings = {
    theme:               'system',
    autoFetchTitle:      'manual',
    autoFetchThumbnail:  'manual',
    wikiEditorMode:      'split' }

// Browser-local settings only. These are device or screen specific.
export type ClientSettings = {
    appearance?:    ClientAppearanceSettings
    behavior?:      ClientBehaviorSettings
    keyboard?:      ClientKeyboardSettings
    panes?:         Record<string, ClientPaneSettings>
    lists?:         Partial<Record<ClientListKey, ClientListSettings>>
    theatre?:       { layoutMode?: TheatreLayoutMode
		      tagFlow?:    TheatreTagFlow }
    gekanator?:     { backgroundMotion?: GekanatorBackgroundMotionMode }
    reducedMotion?: string
    embedAutoLoad?: string
    thumbnailMode?: string }

export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change'
export const USER_THEME_SLOT_NOS: UserThemeSlotNo[] = [1, 2, 3]
export const USER_THEME_SLOT_SELECTIONS: UserThemeSlotSelection[] = [
    'user:light:1',
    'user:light:2',
    'user:light:3',
    'user:dark:1',
    'user:dark:2',
    'user:dark:3']
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
export const POST_LIST_ORDER_OPTIONS = [
    'title:asc',
    'title:desc',
    'url:asc',
    'url:desc',
    'original_created_at:asc',
    'original_created_at:desc',
    'created_at:asc',
    'created_at:desc',
    'updated_at:asc',
    'updated_at:desc'] as const satisfies readonly FetchPostsOrder[]
export const TAG_LIST_ORDER_OPTIONS = [
    'name:asc',
    'name:desc',
    'category:asc',
    'category:desc',
    'post_count:asc',
    'post_count:desc',
    'created_at:asc',
    'created_at:desc',
    'updated_at:asc',
    'updated_at:desc'] as const satisfies readonly FetchTagsOrder[]
export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20
export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc'
export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc'
export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = {
    deerjikist: '#9f1239',
    meme:       '#6b21a8',
    character:  '#4d7c0f',
    general:    '#155e75',
    material:   '#c2410c',
    meta:       '#a16207',
    nico:       '#4b5563' }
export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
    deerjikist: '#fb7185',
    meme:       '#d8b4fe',
    character:  '#bef264',
    general:    '#67e8f9',
    material:   '#fdba74',
    meta:       '#fde047',
    nico:       '#e5e7eb' }
export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS
export const LIGHT_THEME_TOKENS: ThemeTokens = {
    background:            '0 0% 100%',
    foreground:            '222.2 84% 4.9%',
    card:                  '0 0% 100%',
    cardForeground:        '222.2 84% 4.9%',
    popover:               '0 0% 100%',
    popoverForeground:     '222.2 84% 4.9%',
    primary:               '222.2 47.4% 11.2%',
    primaryForeground:     '210 40% 98%',
    secondary:             '210 40% 96.1%',
    secondaryForeground:   '222.2 47.4% 11.2%',
    muted:                 '210 40% 96.1%',
    mutedForeground:       '215.4 16.3% 46.9%',
    accent:                '210 40% 96.1%',
    accentForeground:      '222.2 47.4% 11.2%',
    destructive:           '0 72.2% 50.6%',
    destructiveForeground: '210 40% 98%',
    border:                '214.3 31.8% 91.4%',
    input:                 '214.3 31.8% 91.4%',
    ring:                  '222.2 84% 4.9%',
    link:                  '221.2 83.2% 53.3%',
    topNavRootBackgroundMobile:   '#fef08a',
    topNavRootBackgroundDesktop:  '#fefce8',
    topNavActiveBackground:       '#fef08a',
    topNavSubmenuBackground:      '#fef08a',
    topNavMobileActiveBackground: '#fefce8',
    topNavBrandLink:             '#db2777',
    topNavMenuLink:              '#1d4ed8',
    tagColours:                  DEFAULT_LIGHT_THEME_TAG_COLOURS }
export const DARK_THEME_TOKENS: ThemeTokens = {
    background:            '222.2 84% 4.9%',
    foreground:            '210 40% 98%',
    card:                  '222.2 84% 4.9%',
    cardForeground:        '210 40% 98%',
    popover:               '222.2 84% 4.9%',
    popoverForeground:     '210 40% 98%',
    primary:               '210 40% 98%',
    primaryForeground:     '222.2 47.4% 11.2%',
    secondary:             '217.2 32.6% 17.5%',
    secondaryForeground:   '210 40% 98%',
    muted:                 '217.2 32.6% 17.5%',
    mutedForeground:       '215 20.2% 65.1%',
    accent:                '217.2 32.6% 17.5%',
    accentForeground:      '210 40% 98%',
    destructive:           '0 62.8% 45%',
    destructiveForeground: '210 40% 98%',
    border:                '217.2 32.6% 17.5%',
    input:                 '217.2 32.6% 17.5%',
    ring:                  '212.7 26.8% 83.9%',
    link:                  '213.1 93.9% 67.8%',
    topNavRootBackgroundMobile:   '#230505',
    topNavRootBackgroundDesktop:  '#230505',
    topNavActiveBackground:       '#450a0a',
    topNavSubmenuBackground:      '#450a0a',
    topNavMobileActiveBackground: '#450a0a',
    topNavBrandLink:             '#f9a8d4',
    topNavMenuLink:              '#93c5fd',
    tagColours:                  DEFAULT_DARK_THEME_TAG_COLOURS }
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
    light: { id:         'light',
	     name:       'ライト・モード',
	     builtin:    true,
	     baseTheme:  'light',
	     tokens:     LIGHT_THEME_TOKENS },
    dark: { id:         'dark',
	    name:       'ダーク・モード',
	    builtin:    true,
	    baseTheme:  'dark',
	    tokens:     DARK_THEME_TOKENS } }

const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY = 'gekanator:background-motion:v1'
const LEGACY_MIGRATED_THEME_ID = 'custom:legacy-migrated'


const createCustomThemeId = (): `custom:${ string }` => {
  const randomPart =
      globalThis.crypto?.randomUUID?.()
      ?? `${ Date.now ().toString (36) }-${ Math.random ().toString (36).slice (2) }`

  return `custom:${ randomPart }`
}


export const fetchUserSettings = async (): Promise<UserSettings> => {
  const raw = await apiGet<{
      theme:                 UserSettings['theme']
      auto_fetch_title:      UserSettings['autoFetchTitle']
      auto_fetch_thumbnail:  UserSettings['autoFetchThumbnail']
      wiki_editor_mode:      UserSettings['wikiEditorMode'] }> ('/users/settings')

  return {
      theme:               raw.theme,
      autoFetchTitle:      raw.auto_fetch_title,
      autoFetchThumbnail:  raw.auto_fetch_thumbnail,
      wikiEditorMode:      raw.wiki_editor_mode }
}


export const updateUserSettings = async (
    settings: Partial<{ theme: UserSettings['theme'] }>,
): Promise<UserSettings> => {
  const raw = await apiPatch<{
      theme:               UserSettings['theme']
      auto_fetch_title:      UserSettings['autoFetchTitle']
      auto_fetch_thumbnail:  UserSettings['autoFetchThumbnail']
      wiki_editor_mode:      UserSettings['wikiEditorMode'] }> ('/users/settings', settings)

  return {
      theme:               raw.theme,
      autoFetchTitle:      raw.auto_fetch_title,
      autoFetchThumbnail:  raw.auto_fetch_thumbnail,
      wikiEditorMode:      raw.wiki_editor_mode }
}


const safeParseClientSettings = (raw: string | null): ClientSettings => {
  if (!(raw))
    return { }

  try
  {
    const parsed = JSON.parse (raw)
    return parsed && typeof parsed === 'object' ? parsed as ClientSettings : { }
  }
  catch
  {
    return { }
  }
}


export const loadClientSettings = (): ClientSettings =>
    safeParseClientSettings (localStorage.getItem (CLIENT_SETTINGS_STORAGE_KEY))


export const saveClientSettings = (settings: ClientSettings): void => {
  localStorage.setItem (CLIENT_SETTINGS_STORAGE_KEY, JSON.stringify (settings))

  if (typeof window !== 'undefined')
    {
      window.dispatchEvent (new CustomEvent (CLIENT_SETTINGS_EVENT, {
	  detail: settings }))
    }
}


export const updateClientSettings = (
    updater: (settings: ClientSettings) => ClientSettings,
): ClientSettings => {
  const next = updater (loadClientSettings ())
  saveClientSettings (next)
  return next
}


const validListLimit = (value: unknown): ClientListLimit | null =>
    value === 20 || value === 50 || value === 100 ? value : null


const normaliseThemeMode = (value: unknown): ClientThemeMode | null =>
    value === 'system' || value === 'light' || value === 'dark' ? value : null


const normaliseThemeSlotNo = (value: unknown): UserThemeSlotNo | null =>
    value === 1 || value === 2 || value === 3 ? value : null


const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => {
  if (value === 'normal' || value === 'reduced' || value === 'off')
    return value
  if (value === 'true')
    return 'reduced'
  if (value === 'false')
    return 'normal'
  return null
}


const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | null =>
    value === 'auto' || value === 'manual' || value === 'off' ? value : null


const normaliseLinkPreloadMode = (
    value: unknown,
): ClientLinkPreloadMode | null =>
    value === 'off' || value === 'intent' ? value : null


const normaliseTagRelationDisplayMode = (
    value: unknown,
): ClientTagRelationDisplayMode | null =>
    value === 'flat' || value === 'grouped' ? value : null


const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null =>
    value === 'normal' || value === 'light' || value === 'off' ? value : null


const normaliseKeyboardBindings = (
    bindings: unknown,
): Partial<Record<ShortcutActionId, KeyBinding | null>> => {
  if (!(bindings) || typeof bindings !== 'object')
    return { }

  return (
      Object.keys (DEFAULT_KEY_BINDINGS)
      .reduce<Partial<Record<ShortcutActionId, KeyBinding | null>>> ((normalised, actionId) => {
	const value = (bindings as Record<string, unknown>)[actionId]
	if (value === null)
	  {
	    normalised[actionId as ShortcutActionId] = null
	    return normalised
	  }

	const binding = normaliseKeyBinding (value)
	if (binding != null)
	  normalised[actionId as ShortcutActionId] = binding

	return normalised
      }, { }))
}


export const getClientKeyboardSettings = (): ClientKeyboardSettings => {
  const keyboard = loadClientSettings ().keyboard

  return {
      enabled: keyboard?.enabled !== false,
      bindings: normaliseKeyboardBindings (keyboard?.bindings) }
}


const legacyAnimationMode = (): ClientAnimationMode | null =>
    normaliseAnimationMode (loadClientSettings ().reducedMotion)


const legacyEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode | null =>
    normaliseEmbedAutoLoadMode (loadClientSettings ().embedAutoLoad)


const legacyThumbnailMode = (): ClientThumbnailMode | null =>
    normaliseThumbnailMode (loadClientSettings ().thumbnailMode)


export const getClientAnimationMode = (): ClientAnimationMode =>
    loadClientSettings ().behavior?.animation
    ?? legacyAnimationMode ()
    ?? 'normal'


export const applyClientAnimationMode = (mode: ClientAnimationMode): void => {
  if (typeof document === 'undefined')
    return

  if (mode === 'normal')
    {
      delete document.documentElement.dataset.animation
      return
    }

  document.documentElement.dataset.animation = mode
}


export const setClientAnimationMode = (
    animation: ClientAnimationMode,
): ClientBehaviorSettings =>
    setClientBehaviorSettings ({ ...getClientBehaviorSettings (), animation })


export const getClientLinkPreloadMode = (): ClientLinkPreloadMode =>
    normaliseLinkPreloadMode (loadClientSettings ().behavior?.linkPreload)
    ?? 'intent'


export const setClientLinkPreloadMode = (
    linkPreload: ClientLinkPreloadMode,
): ClientBehaviorSettings =>
    setClientBehaviorSettings ({ ...getClientBehaviorSettings (), linkPreload })


export const getClientTagRelationDisplayMode = (): ClientTagRelationDisplayMode =>
    normaliseTagRelationDisplayMode (loadClientSettings ().behavior?.tagRelationDisplay)
    ?? 'grouped'


export const setClientTagRelationDisplayMode = (
    tagRelationDisplay: ClientTagRelationDisplayMode,
): ClientBehaviorSettings =>
    setClientBehaviorSettings ({ ...getClientBehaviorSettings (), tagRelationDisplay })


export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode =>
    loadClientSettings ().behavior?.embedAutoLoad
    ?? legacyEmbedAutoLoadMode ()
    ?? 'auto'


export const setClientEmbedAutoLoadMode = (
    embedAutoLoad: ClientEmbedAutoLoadMode,
): ClientBehaviorSettings =>
    setClientBehaviorSettings ({ ...getClientBehaviorSettings (), embedAutoLoad })


export const getClientThumbnailMode = (): ClientThumbnailMode =>
    loadClientSettings ().behavior?.thumbnailMode
    ?? legacyThumbnailMode ()
    ?? 'normal'


export const setClientThumbnailMode = (
    thumbnailMode: ClientThumbnailMode,
): ClientBehaviorSettings =>
    setClientBehaviorSettings ({ ...getClientBehaviorSettings (), thumbnailMode })


export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({
    animation:        getClientAnimationMode (),
    linkPreload:      getClientLinkPreloadMode (),
    tagRelationDisplay: getClientTagRelationDisplayMode (),
    embedAutoLoad:    getClientEmbedAutoLoadMode (),
    thumbnailMode:    getClientThumbnailMode () })


export const setClientBehaviorSettings = (
    behavior: ClientBehaviorSettings,
): ClientBehaviorSettings => {
  const next: ClientBehaviorSettings = {
      animation: (
	  normaliseAnimationMode (behavior.animation)
	  ?? getClientAnimationMode ()),
      linkPreload: (
	  normaliseLinkPreloadMode (behavior.linkPreload)
	  ?? getClientLinkPreloadMode ()),
      tagRelationDisplay: (
	  normaliseTagRelationDisplayMode (behavior.tagRelationDisplay)
	  ?? getClientTagRelationDisplayMode ()),
      embedAutoLoad: (
	  normaliseEmbedAutoLoadMode (behavior.embedAutoLoad)
	  ?? getClientEmbedAutoLoadMode ()),
      thumbnailMode: (
	  normaliseThumbnailMode (behavior.thumbnailMode)
	  ?? getClientThumbnailMode ()) }

  updateClientSettings (settings => ({ ...settings, behavior: next }))
  applyClientAnimationMode (next.animation ?? 'normal')
  return next
}


export const setClientKeyboardSettings = (
    keyboard: ClientKeyboardSettings,
): ClientKeyboardSettings => {
  const next: ClientKeyboardSettings = {
      enabled: keyboard.enabled !== false,
      bindings: normaliseKeyboardBindings (keyboard.bindings) }

  updateClientSettings (settings => ({ ...settings, keyboard: next }))

  return next
}


export const getEffectiveKeyBindings = (
    bindings: Partial<Record<ShortcutActionId, KeyBinding | null>> =
	getClientKeyboardSettings ().bindings ?? { },
): Record<ShortcutActionId, KeyBinding | null> =>
    Object.keys (DEFAULT_KEY_BINDINGS).reduce<Record<ShortcutActionId, KeyBinding | null>> (
	(effectiveBindings, actionId) => {
	  const key = actionId as ShortcutActionId
	  effectiveBindings[key] =
	      Object.prototype.hasOwnProperty.call (bindings, key)
	      ? bindings[key] ?? null
	      : DEFAULT_KEY_BINDINGS[key]
	  return effectiveBindings
	},
	{ ...DEFAULT_KEY_BINDINGS })


export const setClientKeyBinding = (
    actionId: ShortcutActionId,
    binding:  KeyBinding | null,
): ClientKeyboardSettings => {
  const current = getClientKeyboardSettings ()

  return setClientKeyboardSettings ({
      ...current,
      bindings: { ...(current.bindings ?? { }), [actionId]: binding } })
}


export const resetClientKeyBinding = (
    actionId: ShortcutActionId,
): ClientKeyboardSettings => {
  const current = getClientKeyboardSettings ()
  const nextBindings = { ...(current.bindings ?? { }) }
  delete nextBindings[actionId]

  return setClientKeyboardSettings ({ ...current, bindings: nextBindings })
}


export const resetAllClientKeyBindings = (): ClientKeyboardSettings => {
  const current = getClientKeyboardSettings ()

  return setClientKeyboardSettings ({ ...current, bindings: { } })
}


const normaliseHexColour = (value: string): string | null =>
    /^#[0-9a-fA-F]{6}$/.test (value) ? value.toLowerCase () : null


const isBuiltinThemeId = (value: string): value is BuiltinThemeId =>
    value === 'light' || value === 'dark'


const isCustomThemeId = (value: string): value is `custom:${ string }` =>
    value.startsWith ('custom:')


const themeCopyName = (baseTheme: BuiltinThemeId): string =>
    `${ BUILTIN_THEMES[baseTheme].name }のコピー`


const appearanceFromSettings = (): ClientAppearanceSettings =>
    loadClientSettings ().appearance ?? { }


let cachedUserThemeSlots: UserThemeSlotMap = { }


export const hasStoredClientThemeSelection = (): boolean => {
  const appearance = appearanceFromSettings ()
  return (appearance.themeMode != null
	  || appearance.activeLightThemeSlotNo != null
	  || appearance.activeDarkThemeSlotNo != null
	  || appearance.activeThemeSlot != null
	  || appearance.activeThemeId != null
	  || appearance.customThemes != null
	  || appearance.theme != null
	  || appearance.tagColours != null)
}


const mixHexColour = (
    source:  string,
    target:  string,
    ratio:   number,
): string => {
  const clampedRatio = Math.max (0, Math.min (1, ratio))
  const src = source.slice (1)
  const dst = target.slice (1)
  const channels = [0, 2, 4].map (offset => {
    const srcValue = parseInt (src.slice (offset, offset + 2), 16)
    const dstValue = parseInt (dst.slice (offset, offset + 2), 16)
    const mixed = Math.round (srcValue + (dstValue - srcValue) * clampedRatio)
    return mixed.toString (16).padStart (2, '0')
  })

  return `#${ channels.join ('') }`
}


// Legacy appearance-model compatibility.
// The current settings UI uses `themeMode` plus the light/dark slot numbers.
// The helpers below remain only to read older localStorage shapes.

const legacyThemeSlotSelection = (): ThemeSlotSelection => {
  const appearance = appearanceFromSettings ()
  const customThemes = getClientCustomThemes ()

  if (appearance.activeThemeId === 'system')
    return 'system'
  if (appearance.activeThemeId === 'light')
    return 'builtin:light'
  if (appearance.activeThemeId === 'dark')
    return 'builtin:dark'
  if (typeof appearance.activeThemeId === 'string'
      && customThemes[appearance.activeThemeId] != null)
    return `builtin:${ customThemes[appearance.activeThemeId].baseTheme }`
  if (appearance.theme === 'light')
    return 'builtin:light'
  if (appearance.theme === 'dark')
    return 'builtin:dark'
  return 'system'
}


const legacyThemeMode = (): ClientThemeMode =>
    normaliseThemeMode (appearanceFromSettings ().theme)
    ?? (legacyThemeSlotSelection () === 'system'
	? 'system'
	: (baseThemeOfSelection (legacyThemeSlotSelection ()) ?? 'system'))


const legacyThemeSlotNoFor = (
    baseTheme: BuiltinThemeId,
): UserThemeSlotNo => {
  const legacySelection = legacyThemeSlotSelection ()

  if (isUserThemeSlotSelection (legacySelection))
    {
      const [, selectionBaseTheme, slotNo] = legacySelection.split (':')
      if (selectionBaseTheme === baseTheme)
	return Number (slotNo) as UserThemeSlotNo
    }

  return 1
}


export const getClientActiveThemeSlot = (): ThemeSlotSelection =>
    normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot)
    ?? legacyThemeSlotSelection ()


// Legacy write path. New code should use `setClientThemeAppearance`.
export const setClientActiveThemeSlot = (
    activeThemeSlot: ThemeSlotSelection,
): void => {
  updateClientSettings (settings => ({
      ...settings,
      appearance: { ...(settings.appearance ?? { }), activeThemeSlot } }))
}


export const getClientThemeMode = (): UserSettings['theme'] =>
    normaliseThemeMode (appearanceFromSettings ().themeMode) ?? legacyThemeMode ()


export const getClientActiveLightThemeSlotNo = (): UserThemeSlotNo =>
    normaliseThemeSlotNo (appearanceFromSettings ().activeLightThemeSlotNo)
    ?? legacyThemeSlotNoFor ('light')


export const getClientActiveDarkThemeSlotNo = (): UserThemeSlotNo =>
    normaliseThemeSlotNo (appearanceFromSettings ().activeDarkThemeSlotNo)
    ?? legacyThemeSlotNoFor ('dark')


export const setClientThemeAppearance = (
    { themeMode,
      activeLightThemeSlotNo,
      activeDarkThemeSlotNo }: { themeMode:              ClientThemeMode
				 activeLightThemeSlotNo: UserThemeSlotNo
				 activeDarkThemeSlotNo:  UserThemeSlotNo },
): void => {
  updateClientSettings (settings => ({
      ...settings,
      appearance: {
	  ...(settings.appearance ?? { }),
	  themeMode,
	  activeLightThemeSlotNo,
	  activeDarkThemeSlotNo } }))
}


export const setClientThemeMode = (theme: UserSettings['theme']): void => {
  setClientThemeAppearance ({
      themeMode: theme,
      activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
      activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () })
}


export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
  if (hasStoredClientThemeSelection ())
    return

  setClientThemeMode (theme)
}


const prefersDarkTheme = (): boolean =>
    window.matchMedia ('(prefers-color-scheme: dark)').matches


export const resolveThemeMode = (
    theme: UserSettings['theme'],
): ResolvedTheme =>
    theme === 'system'
    ? (prefersDarkTheme () ? 'dark' : 'light')
    : theme


export const defaultThemeTagColoursFor = (
    theme: ResolvedTheme,
): ThemeTagColours =>
    theme === 'dark'
    ? DEFAULT_DARK_THEME_TAG_COLOURS
    : DEFAULT_LIGHT_THEME_TAG_COLOURS


const normaliseThemeTagColours = (
    input:     Partial<ThemeTagColours> | null | undefined,
    fallback:  ThemeTagColours,
): ThemeTagColours =>
    CATEGORIES.reduce<ThemeTagColours> (
	(colours, category) => {
	  const candidate = input?.[category]
	  colours[category] =
	      typeof candidate === 'string' && normaliseHexColour (candidate) != null
	      ? candidate
	      : fallback[category]
	  return colours
	},
	{ ...fallback })


const normaliseThemeTokenValue = (
    value:     unknown,
    fallback:  string,
): string =>
    typeof value === 'string' && value !== '' ? value : fallback


const cssColourFromThemeToken = (
    value:     unknown,
    fallback:  string,
): string => {
  if (typeof value !== 'string' || value === '')
    return fallback

  const normalisedHex = normaliseHexColour (value)
  if (normalisedHex != null)
    return normalisedHex

  return (/^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test (value)
	  ? `hsl(${ value })`
	  : fallback)
}


const clamp = (value: number, min: number, max: number): number =>
    Math.min (Math.max (value, min), max)


const parseHslComponents = (
    value: string,
): { h: number; s: number; l: number } | null => {
  const raw = (value.startsWith ('hsl(') && value.endsWith (')')
	       ? value.slice (4, -1)
	       : value)
  const match =
      raw.match (/^(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%$/)

  if (match == null)
    return null

  return {
      h: Number (match[1]),
      s: Number (match[2]),
      l: Number (match[3]) }
}


const formatHslToken = (
    { h, s, l }: { h: number
		   s: number
		   l: number },
): string =>
    `${ h.toFixed (1).replace (/\.0$/, '') }`
    + ` ${ s.toFixed (1).replace (/\.0$/, '') }%`
    + ` ${ l.toFixed (1).replace (/\.0$/, '') }%`


const hslLightness = (value: string): number | null =>
    parseHslComponents (value)?.l ?? null


const readableForegroundFor = (value: string): string =>
    (hslLightness (value) ?? 50) < 56
    ? LIGHT_THEME_TOKENS.primaryForeground
    : DARK_THEME_TOKENS.primaryForeground


const deriveMutedForeground = (
    foreground: string,
    background: string,
    fallback:   string,
): string => {
  const foregroundHsl = parseHslComponents (foreground)
  const backgroundHsl = parseHslComponents (background)

  if (foregroundHsl == null || backgroundHsl == null)
    return fallback

  return formatHslToken ({
      h: foregroundHsl.h,
      s: clamp (foregroundHsl.s * .6, 0, 100),
      l: clamp (
	  foregroundHsl.l + (backgroundHsl.l - foregroundHsl.l) * .35,
	  0,
	  100) })
}


const deriveTopNavTokens = (
    baseTheme:  BuiltinThemeId,
    editable:   EditableTopNavTokens,
    link:       string,
): ResolvedTopNavTokens => ({
    rootBackgroundMobile:   editable.rootBackgroundMobile,
    rootBackgroundDesktop:  editable.rootBackgroundDesktop,
    activeBackground:       editable.activeBackground,
    submenuBackground:      editable.activeBackground,
    mobileMenuBackground:   editable.rootBackgroundMobile,
    mobileActiveBackground: (
	baseTheme === 'dark'
	? editable.activeBackground
	: editable.rootBackgroundDesktop),
    brandLink:              editable.brandLink,
    menuLink:               link })


export const buildThemeTokens = (
    baseTheme:  BuiltinThemeId,
    rawTokens:  unknown,
): ThemeTokens => {
  const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS
  const tokens =
      rawTokens && typeof rawTokens === 'object'
      ? rawTokens as Partial<ThemeTokens>
      : { }
  const legacyTopNavTokens =
      rawTokens && typeof rawTokens === 'object'
      ? rawTokens as Record<string, unknown>
      : { }

  const background =
      normaliseThemeTokenValue (tokens.background, fallback.background)
  const foreground =
      normaliseThemeTokenValue (tokens.foreground, fallback.foreground)
  const card =
      normaliseThemeTokenValue (tokens.card, fallback.card)
  const muted =
      normaliseThemeTokenValue (tokens.muted, fallback.muted)
  const primary =
      normaliseThemeTokenValue (tokens.primary, fallback.primary)
  const accent =
      normaliseThemeTokenValue (tokens.accent, fallback.accent)
  const border =
      normaliseThemeTokenValue (tokens.border, fallback.border)
  const link =
      normaliseThemeTokenValue (tokens.link, fallback.link)
  const topNavRootBackgroundMobile = normaliseTopNavColourValue (
      tokens.topNavRootBackgroundMobile
      ?? legacyTopNavTokens.topNavRootBackgroundMobile
      ?? legacyTopNavTokens.topNavBackground,
      fallback.topNavRootBackgroundMobile)
  const topNavRootBackgroundDesktop = normaliseTopNavColourValue (
      tokens.topNavRootBackgroundDesktop
      ?? legacyTopNavTokens.topNavRootBackgroundDesktop
      ?? legacyTopNavTokens.topNavBackground,
      fallback.topNavRootBackgroundDesktop)
  const topNavActiveBackground = normaliseTopNavColourValue (
      tokens.topNavActiveBackground
      ?? legacyTopNavTokens.topNavActiveBackground
      ?? legacyTopNavTokens.topNavHighlightBackground
      ?? legacyTopNavTokens.topNavHighlightBackground,
      fallback.topNavActiveBackground)
  const topNavBrandLink = normaliseTopNavColourValue (
      tokens.topNavBrandLink
      ?? legacyTopNavTokens.topNavBrandLink
      ?? legacyTopNavTokens.topNavLink,
      fallback.topNavBrandLink)
  const topNavMenuLink = cssColourFromThemeToken (
      link,
      fallback.topNavMenuLink)
  const resolvedTopNav = deriveTopNavTokens (
      baseTheme,
      { rootBackgroundMobile: topNavRootBackgroundMobile,
	rootBackgroundDesktop: topNavRootBackgroundDesktop,
	activeBackground: topNavActiveBackground,
	brandLink: topNavBrandLink },
      topNavMenuLink)

  return {
      background,
      foreground,
      card,
      cardForeground: foreground,
      popover: card,
      popoverForeground: foreground,
      primary,
      primaryForeground: readableForegroundFor (primary),
      secondary: muted,
      secondaryForeground: foreground,
      muted,
      mutedForeground: deriveMutedForeground (
	  foreground,
	  background,
	  fallback.mutedForeground),
      accent,
      accentForeground: readableForegroundFor (accent),
      destructive: fallback.destructive,
      destructiveForeground: fallback.destructiveForeground,
      border,
      input: border,
      ring: primary,
      link,
      topNavRootBackgroundMobile: resolvedTopNav.rootBackgroundMobile,
      topNavRootBackgroundDesktop: resolvedTopNav.rootBackgroundDesktop,
      topNavActiveBackground: resolvedTopNav.activeBackground,
      topNavSubmenuBackground: resolvedTopNav.submenuBackground,
      topNavMobileActiveBackground: resolvedTopNav.mobileActiveBackground,
      topNavBrandLink: resolvedTopNav.brandLink,
      topNavMenuLink: resolvedTopNav.menuLink,
      tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours) }
}


const normaliseTopNavColourValue = (
    value:     unknown,
    fallback:  string,
): string => cssColourFromThemeToken (value, fallback)


const normaliseThemeTokens = (
    rawTokens:   unknown,
    baseTheme:   BuiltinThemeId,
): ThemeTokens => {
  const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS
  const tokens =
      rawTokens && typeof rawTokens === 'object'
      ? rawTokens as Partial<ThemeTokens>
      : { }
  const legacyTopNavTokens =
      rawTokens && typeof rawTokens === 'object'
      ? rawTokens as Record<string, unknown>
      : { }

  const background =
      normaliseThemeTokenValue (tokens.background, fallback.background)
  const foreground =
      normaliseThemeTokenValue (tokens.foreground, fallback.foreground)
  const card =
      normaliseThemeTokenValue (tokens.card, fallback.card)
  const muted =
      normaliseThemeTokenValue (tokens.muted, fallback.muted)
  const primary =
      normaliseThemeTokenValue (tokens.primary, fallback.primary)
  const accent =
      normaliseThemeTokenValue (tokens.accent, fallback.accent)
  const border =
      normaliseThemeTokenValue (tokens.border, fallback.border)
  const link =
      normaliseThemeTokenValue (tokens.link, fallback.link)

  return buildThemeTokens (
      baseTheme,
      { ...tokens,
	background,
	foreground,
	card,
	muted,
	primary,
	accent,
	border,
	link,
	topNavRootBackgroundMobile:
	    tokens.topNavRootBackgroundMobile
	    ?? legacyTopNavTokens.topNavRootBackgroundMobile
	    ?? legacyTopNavTokens.topNavBackground,
	topNavRootBackgroundDesktop:
	    tokens.topNavRootBackgroundDesktop
	    ?? legacyTopNavTokens.topNavRootBackgroundDesktop
	    ?? legacyTopNavTokens.topNavBackground,
	topNavActiveBackground:
	    tokens.topNavActiveBackground
	    ?? legacyTopNavTokens.topNavActiveBackground
	    ?? legacyTopNavTokens.topNavHighlightBackground,
	topNavBrandLink:
	    tokens.topNavBrandLink
	    ?? legacyTopNavTokens.topNavBrandLink
	    ?? legacyTopNavTokens.topNavLink,
	topNavMenuLink:
	    tokens.topNavMenuLink
	    ?? legacyTopNavTokens.topNavMenuLink
	    ?? link,
	tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours) })
}


export const getUserThemeSlotSelection = (
    baseTheme:  BuiltinThemeId,
    slotNo:     UserThemeSlotNo,
): UserThemeSlotSelection => `user:${ baseTheme }:${ slotNo }`


const isBuiltinThemeSlotSelection = (
    value: string,
): value is `builtin:${ BuiltinThemeId }` =>
    value === 'builtin:light' || value === 'builtin:dark'


export const isUserThemeSlotSelection = (
    value: unknown,
): value is UserThemeSlotSelection =>
    typeof value === 'string'
    && USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection)


const normaliseThemeSlotSelection = (
    value: unknown,
): ThemeSlotSelection | null => {
  if (value === 'system')
    return 'system'
  if (typeof value === 'string' && isBuiltinThemeSlotSelection (value))
    return value
  if (isUserThemeSlotSelection (value))
    return value
  return null
}


const baseThemeOfSelection = (
    selection: ThemeSlotSelection,
): BuiltinThemeId | null => {
  if (selection === 'system')
    return null
  if (isBuiltinThemeSlotSelection (selection))
    return selection.slice ('builtin:'.length) as BuiltinThemeId

  return selection.split (':')[1] as BuiltinThemeId
}


const slotNoOfSelection = (
    selection: UserThemeSlotSelection,
): UserThemeSlotNo => Number (selection.split (':')[2]) as UserThemeSlotNo


export const getDefaultUserThemeSlot = (
    baseTheme:  BuiltinThemeId,
    slotNo:     UserThemeSlotNo,
): UserThemeSlot => ({
    baseTheme,
    slotNo,
    tokens: normaliseThemeTokens ({ }, baseTheme) })


const normaliseUserThemeSlot = (
    rawSlot: unknown,
): UserThemeSlot | null => {
  if (!(rawSlot) || typeof rawSlot !== 'object')
    return null

  const slot = rawSlot as Partial<UserThemeSlot>
  const baseTheme = slot.baseTheme
  const slotNo = slot.slotNo

  if (baseTheme == null
      || !(isBuiltinThemeId (baseTheme))
      || slotNo == null
      || !(USER_THEME_SLOT_NOS.includes (slotNo as UserThemeSlotNo)))
    return null

  return {
      baseTheme,
      slotNo: slotNo as UserThemeSlotNo,
      tokens: normaliseThemeTokens (slot.tokens, baseTheme),
      createdAt: typeof slot.createdAt === 'string' ? slot.createdAt : undefined,
      updatedAt: typeof slot.updatedAt === 'string' ? slot.updatedAt : undefined }
}


export const normaliseUserThemeSlots = (
    slots: UserThemeSlot[],
): UserThemeSlotMap =>
    slots.reduce<UserThemeSlotMap> (
	(map, slot) => {
	  map[getUserThemeSlotSelection (slot.baseTheme, slot.slotNo)] = slot
	  return map
	},
	{ })


const normaliseCustomTheme = (
    rawTheme:    unknown,
): CustomClientTheme | null => {
  if (!(rawTheme) || typeof rawTheme !== 'object')
    return null

  const theme = rawTheme as Record<string, unknown>
  const id = typeof theme.id === 'string' ? theme.id : null
  const name = typeof theme.name === 'string' ? theme.name : null
  const baseTheme = typeof theme.baseTheme === 'string' ? theme.baseTheme : null
  if (id == null
      || !(isCustomThemeId (id))
      || name == null
      || baseTheme == null
      || !(isBuiltinThemeId (baseTheme)))
    return null

  return {
      id,
      name,
      builtin: false,
      baseTheme,
      tokens: normaliseThemeTokens (
	  { ...(theme.tokens && typeof theme.tokens === 'object'
		? theme.tokens as Partial<ThemeTokens>
		: { }),
	    tagColours: (
		(theme.tokens
		 && typeof theme.tokens === 'object'
		 && 'tagColours' in theme.tokens)
		? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours
		: theme.tagColours as Partial<ThemeTagColours> | undefined) },
	  baseTheme) }
}


export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
  const appearance = appearanceFromSettings ()
  const customThemes = appearance.customThemes

  if (customThemes && typeof customThemes === 'object')
    {
      return Object.entries (customThemes).reduce<Record<string, CustomClientTheme>> (
	  (themes, [key, rawTheme]) => {
	    const theme = normaliseCustomTheme (rawTheme)
	    if (theme != null)
	      themes[key] = theme
	    return themes
	  },
	  { })
    }

  const legacyTagColours = appearance.tagColours
  if (!(legacyTagColours) || Object.keys (legacyTagColours).length === 0)
    return { }

  const baseTheme = resolveThemeMode (appearance.theme ?? DEFAULT_USER_SETTINGS.theme)
  return {
      [LEGACY_MIGRATED_THEME_ID]: {
	  id: LEGACY_MIGRATED_THEME_ID,
	  name: themeCopyName (baseTheme),
	  builtin: false,
	  baseTheme,
	  tokens: {
	      ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS),
	      tagColours: normaliseThemeTagColours (
		  legacyTagColours,
		  defaultThemeTagColoursFor (baseTheme)) } } }
}


export const fetchUserThemeSlots = async (): Promise<UserThemeSlot[]> => {
  const raw = await apiGet<Array<{
      baseTheme: BuiltinThemeId
      slotNo: UserThemeSlotNo
      tokens: ThemeTokens
      createdAt: string
      updatedAt: string }>> ('/users/theme_slots')

  return (raw
	  .map (slot => normaliseUserThemeSlot (slot))
	  .filter ((slot): slot is UserThemeSlot => slot != null))
}


export const upsertUserThemeSlot = async (
    baseTheme:  BuiltinThemeId,
    slotNo:     UserThemeSlotNo,
    tokens:     ThemeTokens,
): Promise<UserThemeSlot> => {
  const raw = await apiPut<{
      baseTheme: BuiltinThemeId
      slotNo: UserThemeSlotNo
      tokens: ThemeTokens
      createdAt: string
      updatedAt: string }> (`/users/theme_slots/${ baseTheme }/${ slotNo }`, { tokens })
  const slot = normaliseUserThemeSlot (raw)

  if (slot == null)
    throw new Error ('theme slot response is invalid')

  return slot
}


export const setCachedUserThemeSlots = (themeSlots: UserThemeSlotMap): void => {
  cachedUserThemeSlots = themeSlots
}


export const getCachedUserThemeSlots = (): UserThemeSlotMap =>
    cachedUserThemeSlots


export const resolveDisplayedTheme = (
    { themeMode,
      activeLightThemeSlotNo,
      activeDarkThemeSlotNo }: { themeMode:              ClientThemeMode
				 activeLightThemeSlotNo: UserThemeSlotNo
				 activeDarkThemeSlotNo:  UserThemeSlotNo },
    themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): ResolvedDisplayedTheme => {
  const baseTheme = resolveThemeMode (themeMode)
  const slotNo =
      baseTheme === 'dark'
      ? activeDarkThemeSlotNo
      : activeLightThemeSlotNo
  const selection = getUserThemeSlotSelection (baseTheme, slotNo)

  return {
      themeMode,
      baseTheme,
      selection,
      slotNo,
      tokens: (themeSlots[selection]?.tokens
	       ?? getDefaultUserThemeSlot (baseTheme, slotNo).tokens) }
}


export const normaliseThemeSlotSelectionAgainstSlots = (
    selection:   ThemeSlotSelection,
    themeSlots:  UserThemeSlotMap,
): ThemeSlotSelection =>
    isUserThemeSlotSelection (selection) && themeSlots[selection] == null
    ? 'system'
    : selection


export const getClientActiveThemeId = (): ActiveThemeId => {
  const appearance = appearanceFromSettings ()
  const customThemes = getClientCustomThemes ()

  if (typeof appearance.activeThemeId === 'string'
      && (appearance.activeThemeId === 'system'
	  || isBuiltinThemeId (appearance.activeThemeId)
	  || customThemes[appearance.activeThemeId] != null))
    return appearance.activeThemeId

  if (Object.keys (customThemes).length > 0
      && appearance.tagColours != null
      && Object.keys (appearance.tagColours).length > 0)
    return LEGACY_MIGRATED_THEME_ID

  if (typeof appearance.theme === 'string'
      && (appearance.theme === 'system'
	  || appearance.theme === 'light'
	  || appearance.theme === 'dark'))
    return appearance.theme

  return DEFAULT_USER_SETTINGS.theme
}


export const getThemeById = (
    themeId:       ThemeId,
    customThemes:  Record<string, CustomClientTheme>,
): ClientTheme =>
    isBuiltinThemeId (themeId)
    ? BUILTIN_THEMES[themeId]
    : (customThemes[themeId] ?? BUILTIN_THEMES.light)


export const getResolvedThemeFromSelection = (
    activeThemeId: ActiveThemeId,
    customThemes:  Record<string, CustomClientTheme>,
): ClientTheme =>
    activeThemeId === 'system'
    ? BUILTIN_THEMES[resolveThemeMode ('system')]
    : getThemeById (activeThemeId, customThemes)


export const deriveUserThemeModeFromSelection = (
    activeThemeId: ActiveThemeId,
    customThemes:  Record<string, CustomClientTheme>,
): UserSettings['theme'] => {
  if (activeThemeId === 'system')
    return 'system'

  return getThemeById (activeThemeId, customThemes).baseTheme
}


export const resolveThemeFromSlotSelection = (
    selection:   ThemeSlotSelection,
    themeSlots:  UserThemeSlotMap = getCachedUserThemeSlots (),
): ResolvedThemeSelection => {
  if (selection === 'system')
    {
      const baseTheme = resolveThemeMode ('system')
      return {
	  selection,
	  baseTheme,
	  builtin: true,
	  tokens: BUILTIN_THEMES[baseTheme].tokens }
    }

  if (isBuiltinThemeSlotSelection (selection))
    {
      const baseTheme = selection.slice ('builtin:'.length) as BuiltinThemeId
      return {
	  selection,
	  baseTheme,
	  builtin: true,
	  tokens: BUILTIN_THEMES[baseTheme].tokens }
    }

  const baseTheme = baseThemeOfSelection (selection) ?? 'light'
  return {
      selection,
      baseTheme,
      builtin: false,
      tokens: (themeSlots[selection]?.tokens
	       ?? getDefaultUserThemeSlot (baseTheme, slotNoOfSelection (selection)).tokens) }
}


export const deriveUserThemeModeFromSlotSelection = (
    selection: ThemeSlotSelection,
): UserSettings['theme'] =>
    selection === 'system'
    ? 'system'
    : (baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme)


export const createCustomThemeFromBase = (
    baseTheme:   BuiltinThemeId,
    tokens?:     ThemeTokens,
): CustomClientTheme => {
  const id = createCustomThemeId ()
  return {
      id,
      name: themeCopyName (baseTheme),
      builtin: false,
      baseTheme,
      tokens: tokens ?? { ...(baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS) } }
}


export const getClientThemeTagColours = (
    activeThemeId: ActiveThemeId = getClientActiveThemeId (),
    customThemes:  Record<string, CustomClientTheme> = getClientCustomThemes (),
): ThemeTagColours =>
    ({ ...getResolvedThemeFromSelection (activeThemeId, customThemes).tokens.tagColours })


export const setClientAppearanceThemes = (
    activeThemeId: ActiveThemeId,
    customThemes:  Record<string, CustomClientTheme>,
): void => {
  updateClientSettings (settings => ({
      ...settings,
      appearance: {
	  ...(settings.appearance ?? { }),
	  activeThemeId,
	  customThemes,
	  theme: undefined,
	  tagColours: undefined } }))
}


export const setClientThemeTagColours = (
    tagColours:     ThemeTagColours,
    activeThemeId:  ActiveThemeId = getClientActiveThemeId (),
    customThemes:   Record<string, CustomClientTheme> = getClientCustomThemes (),
): void => {
  if (activeThemeId === 'system' || isBuiltinThemeId (activeThemeId))
    return

  setClientAppearanceThemes (
      activeThemeId,
      { ...customThemes,
	[activeThemeId]: {
	    ...customThemes[activeThemeId],
	    tokens: { ...customThemes[activeThemeId].tokens, tagColours } } })
}


export const applyThemeMode = (theme: UserSettings['theme']): void => {
  const root = document.documentElement
  const resolvedTheme = resolveThemeMode (theme)

  root.classList.toggle ('dark', resolvedTheme === 'dark')
  root.style.colorScheme = resolvedTheme
}


export const applyThemeTagColours = (tagColours: ThemeTagColours): void => {
  const root = document.documentElement

  CATEGORIES.forEach (category => {
    const colour = tagColours[category]
    root.style.setProperty (`--tag-colour-${ category }`, colour)
    root.style.setProperty (
	`--tag-colour-${ category }-hover`,
	mixHexColour (colour, '#ffffff', .18))
  })
}


export const applyThemeTokens = (tokens: ThemeTokens): void => {
  const root = document.documentElement

  root.style.setProperty ('--background', tokens.background)
  root.style.setProperty ('--foreground', tokens.foreground)
  root.style.setProperty ('--card', tokens.card)
  root.style.setProperty ('--card-foreground', tokens.cardForeground)
  root.style.setProperty ('--popover', tokens.popover)
  root.style.setProperty ('--popover-foreground', tokens.popoverForeground)
  root.style.setProperty ('--primary', tokens.primary)
  root.style.setProperty ('--primary-foreground', tokens.primaryForeground)
  root.style.setProperty ('--secondary', tokens.secondary)
  root.style.setProperty ('--secondary-foreground', tokens.secondaryForeground)
  root.style.setProperty ('--muted', tokens.muted)
  root.style.setProperty ('--muted-foreground', tokens.mutedForeground)
  root.style.setProperty ('--accent', tokens.accent)
  root.style.setProperty ('--accent-foreground', tokens.accentForeground)
  root.style.setProperty ('--destructive', tokens.destructive)
  root.style.setProperty ('--destructive-foreground', tokens.destructiveForeground)
  root.style.setProperty ('--border', tokens.border)
  root.style.setProperty ('--input', tokens.input)
  root.style.setProperty ('--ring', tokens.ring)
  root.style.setProperty ('--theme-link', tokens.link)
  root.style.setProperty ('--top-nav-root-bg-mobile', tokens.topNavRootBackgroundMobile)
  root.style.setProperty ('--top-nav-root-bg-desktop', tokens.topNavRootBackgroundDesktop)
  root.style.setProperty ('--top-nav-active-bg', tokens.topNavActiveBackground)
  root.style.setProperty ('--top-nav-submenu-bg', tokens.topNavSubmenuBackground)
  root.style.setProperty ('--top-nav-mobile-menu-bg', tokens.topNavRootBackgroundMobile)
  root.style.setProperty ('--top-nav-mobile-active-bg', tokens.topNavMobileActiveBackground)
  root.style.setProperty ('--top-nav-brand-link', tokens.topNavBrandLink)
  root.style.setProperty ('--top-nav-menu-link', tokens.topNavMenuLink)

  applyThemeTagColours (tokens.tagColours)
}


export const applyThemeSelection = (
    activeThemeSlot: ThemeSlotSelection,
    themeSlots:      UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
  const theme = resolveThemeFromSlotSelection (activeThemeSlot, themeSlots)
  applyThemeMode (theme.baseTheme)
  applyThemeTokens (theme.tokens)
}


export const applyThemeAppearanceState = (
    { themeMode,
      activeLightThemeSlotNo,
      activeDarkThemeSlotNo }: { themeMode:              ClientThemeMode
				 activeLightThemeSlotNo: UserThemeSlotNo
				 activeDarkThemeSlotNo:  UserThemeSlotNo },
    themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
  const theme = resolveDisplayedTheme ({ themeMode,
					 activeLightThemeSlotNo,
					 activeDarkThemeSlotNo }, themeSlots)

  applyThemeMode (theme.baseTheme)
  applyThemeTokens (theme.tokens)
}


export const applyClientAppearance = (
    themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
  applyThemeAppearanceState ({
      themeMode: getClientThemeMode (),
      activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
      activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo () }, themeSlots)
}


export const getClientListLimit = (
    listKey: ClientListKey,
): ClientListLimit | null => {
  const value = loadClientSettings ().lists?.[listKey]?.limit
  return validListLimit (value)
}


export const getClientListOrder = <T extends string>(
    listKey: ClientListKey,
): T | null => {
  const value = loadClientSettings ().lists?.[listKey]?.order
  return typeof value === 'string' ? value as T : null
}


export const setClientListSettings = (
    listKey: ClientListKey,
    { limit, order }: { limit?: ClientListLimit
			order?: string },
): void => {
  updateClientSettings (settings => ({
      ...settings,
      lists: {
	  ...(settings.lists ?? { }),
	  [listKey]: {
	      ...(settings.lists?.[listKey] ?? { }),
	      ...(limit == null ? { } : { limit }),
	      ...(order == null ? { } : { order }) } } }))
}


const legacyTheatreLayoutMode = (): TheatreLayoutMode | null => {
  const value = localStorage.getItem (LEGACY_THEATRE_LAYOUT_STORAGE_KEY)
  return ((value === 'threeColumns'
	   || value === 'tagsBottom'
	   || value === 'commentsBottom')
	  ? value
	  : null)
}


const legacyTheatreTagFlow = (): TheatreTagFlow | null => {
  const value = localStorage.getItem (LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY)
  return (value === 'vertical' || value === 'horizontal') ? value : null
}


const legacyGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode | null => {
  const value = localStorage.getItem (LEGACY_GEKANATOR_BACKGROUND_MOTION_STORAGE_KEY)
  return (value === 'on' || value === 'calm' || value === 'off') ? value : null
}


export const getClientTheatreLayoutMode = (): TheatreLayoutMode =>
    loadClientSettings ().theatre?.layoutMode
    ?? legacyTheatreLayoutMode ()
    ?? 'threeColumns'


export const setClientTheatreLayoutMode = (layoutMode: TheatreLayoutMode): void => {
  updateClientSettings (settings => ({
      ...settings,
      theatre: { ...(settings.theatre ?? { }), layoutMode } }))
}


export const getClientTheatreTagFlow = (): TheatreTagFlow =>
    loadClientSettings ().theatre?.tagFlow
    ?? legacyTheatreTagFlow ()
    ?? 'vertical'


export const setClientTheatreTagFlow = (tagFlow: TheatreTagFlow): void => {
  updateClientSettings (settings => ({
      ...settings,
      theatre: { ...(settings.theatre ?? { }), tagFlow } }))
}


export const getClientGekanatorBackgroundMotion = (): GekanatorBackgroundMotionMode =>
    loadClientSettings ().gekanator?.backgroundMotion
    ?? legacyGekanatorBackgroundMotion ()
    ?? 'on'


export const setClientGekanatorBackgroundMotion = (
    backgroundMotion: GekanatorBackgroundMotionMode,
): void => {
  updateClientSettings (settings => ({
      ...settings,
      gekanator: { ...(settings.gekanator ?? { }), backgroundMotion } }))
}


export const getClientPaneWidthPx = (
    paneKey:      string,
    breakpoint:   ClientPaneBreakpoint,
): number | null => {
  const value = loadClientSettings ().panes?.[paneKey]?.widthPxByBreakpoint?.[breakpoint]
  return typeof value === 'number' ? value : null
}


export const setClientPaneWidthPx = (
    paneKey:      string,
    breakpoint:   ClientPaneBreakpoint,
    widthPx:      number,
): void => {
  updateClientSettings (settings => ({
      ...settings,
      panes: {
	  ...(settings.panes ?? { }),
	  [paneKey]: {
	      ...(settings.panes?.[paneKey] ?? { }),
	      widthPxByBreakpoint: {
		  ...(settings.panes?.[paneKey]?.widthPxByBreakpoint ?? { }),
		  [breakpoint]: widthPx } } } }))
}


export const setClientPaneCollapsed = (
    paneKey:      string,
    collapsed:    boolean,
): void => {
  updateClientSettings (settings => ({
      ...settings,
      panes: {
	  ...(settings.panes ?? { }),
	  [paneKey]: {
	      ...(settings.panes?.[paneKey] ?? { }),
	      collapsed } } }))
}
