/* eslint-disable react-refresh/only-export-components */
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
  type ReactNode,
} from 'react'
import { Capacitor } from '@capacitor/core'

export type InputDevice = 'pc' | 'controller'
export type ControllerIconStyle = 'xbox' | 'playstation' | 'nintendo'
export type MovementVector = {
  x: number
  y: number
}

export const INPUT_ACTIONS = [
  'navigateUp',
  'navigateDown',
  'navigateLeft',
  'navigateRight',
  'confirm',
  'back',
  'ability1',
  'ability2',
  'ability3',
  'ability4',
  'ability5',
  'ability6',
  'previousTarget',
  'nextTarget',
  'targetParty1',
  'targetParty2',
  'targetParty3',
  'targetParty4',
  'targetParty5',
  'targetParty6',
  'toggleTargetGroup',
  'toggleSpeed',
  'toggleTouchLock',
  'pause',
] as const

export type InputAction = typeof INPUT_ACTIONS[number]
export type InputBindings = Record<InputAction, string>

export const ACTION_LABELS: Record<InputAction, string> = {
  navigateUp: 'Navigate Up',
  navigateDown: 'Navigate Down',
  navigateLeft: 'Navigate Left',
  navigateRight: 'Navigate Right',
  confirm: 'Confirm / Select',
  back: 'Back',
  ability1: 'Ability Slot 1',
  ability2: 'Ability Slot 2',
  ability3: 'Ability Slot 3',
  ability4: 'Ability Slot 4',
  ability5: 'Ability Slot 5',
  ability6: 'Ability Slot 6',
  previousTarget: 'Previous Party Target',
  nextTarget: 'Next Party Target',
  targetParty1: 'Target Party Member 1',
  targetParty2: 'Target Party Member 2',
  targetParty3: 'Target Party Member 3',
  targetParty4: 'Target Party Member 4',
  targetParty5: 'Target Party Member 5',
  targetParty6: 'Target Party Member 6',
  toggleTargetGroup: 'Switch Raid Target Group',
  toggleSpeed: 'Toggle 2x Speed',
  toggleTouchLock: 'Toggle Combat Touch Lock',
  pause: 'Pause Menu',
}

export const DEFAULT_BINDINGS: Record<InputDevice, InputBindings> = {
  pc: {
    navigateUp: 'ArrowUp',
    navigateDown: 'ArrowDown',
    navigateLeft: 'ArrowLeft',
    navigateRight: 'ArrowRight',
    confirm: 'Enter',
    back: 'Escape',
    ability1: 'Digit1',
    ability2: 'Digit2',
    ability3: 'Digit3',
    ability4: 'Digit4',
    ability5: 'Digit5',
    ability6: 'Digit6',
    previousTarget: 'KeyQ',
    nextTarget: 'KeyE',
    targetParty1: 'F1',
    targetParty2: 'F2',
    targetParty3: 'F3',
    targetParty4: 'F4',
    targetParty5: 'F5',
    targetParty6: 'F6',
    toggleTargetGroup: 'Tab',
    toggleSpeed: 'Backquote',
    toggleTouchLock: 'F7',
    pause: 'Escape',
  },
  controller: {
    navigateUp: 'Axis1-',
    navigateDown: 'Axis1+',
    navigateLeft: 'Axis0-',
    navigateRight: 'Axis0+',
    confirm: 'Button0',
    back: 'Button1',
    ability1: 'Button3',
    ability2: 'Button2',
    ability3: 'Button0',
    ability4: 'Button1',
    ability5: 'Button5',
    ability6: 'Button7',
    previousTarget: 'Button14',
    nextTarget: 'Button15',
    targetParty1: 'Button14',
    targetParty2: 'Button12',
    targetParty3: 'Button15',
    targetParty4: 'Button13',
    targetParty5: 'Button4',
    targetParty6: 'Button6',
    toggleTargetGroup: 'Button8',
    toggleSpeed: 'Button11',
    toggleTouchLock: 'Button10',
    pause: 'Button9',
  },
}

const STORAGE_KEY = 'ashen-halls-input-bindings-v1'
const PREFERENCES_STORAGE_KEY = 'ashen-halls-input-preferences-v1'
const GAME_ACTION_EVENT = 'ashen-halls-game-action'
const NATIVE_CONTROLLER_EVENT = 'ashen-halls-native-controller'
const NATIVE_CONTROLLER_MOTION_EVENT = 'ashen-halls-native-controller-motion'
const FOCUSABLE_SELECTOR = 'button:not(:disabled):not([data-controller-nav="skip"]), input:not(:disabled):not([data-controller-nav="skip"]), select:not(:disabled):not([data-controller-nav="skip"]), textarea:not(:disabled):not([data-controller-nav="skip"]), [tabindex]:not([tabindex="-1"]):not([data-controller-nav="skip"])'
const MAIN_CONTENT_SELECTOR = '.auth-shell, .menu-screen, .content-screen, .dungeon-run-screen, .dual-bottom-display, .iwt2-bottom-display'
const HEADER_CONTENT_SELECTOR = '.app-header'
const GAMEPAD_COMBAT_POLL_MS = 1000 / 60
const GAMEPAD_MENU_POLL_MS = 1000 / 30
const GAMEPAD_BROWSER_DISCONNECTED_POLL_MS = 250
const CONTROLLER_REPEAT_INITIAL_MS = 260
const CONTROLLER_REPEAT_MS = 85
const NATIVE_STICK_TIMEOUT_MS = 140
const DPAD_NAV_ACTIONS: Partial<Record<string, InputAction>> = {
  Button12: 'navigateUp',
  Button13: 'navigateDown',
  Button14: 'navigateLeft',
  Button15: 'navigateRight',
}

let lastControllerFocus: HTMLElement | null = null

type CaptureState = {
  device: InputDevice
  action: InputAction
} | null

type InputContextValue = {
  bindings: Record<InputDevice, InputBindings>
  capture: CaptureState
  lastDevice: InputDevice
  controllerIconStyle: ControllerIconStyle
  directPartyTargeting: boolean
  combatTouchLocked: boolean
  beginCapture: (device: InputDevice, action: InputAction) => void
  cancelCapture: () => void
  resetBindings: (device: InputDevice) => void
  setControllerIconStyle: (style: ControllerIconStyle) => void
  setDirectPartyTargeting: (enabled: boolean) => void
  setCombatTouchLocked: (locked: boolean) => void
}

const InputContext = createContext<InputContextValue | null>(null)

function loadBindings(): Record<InputDevice, InputBindings> {
  try {
    const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Partial<Record<InputDevice, Partial<InputBindings>>>
    const savedController = saved.controller
    const controller = { ...DEFAULT_BINDINGS.controller, ...savedController }
    const usesLegacyAbilityDefaults = [
      'Button2',
      'Button3',
      'Button4',
      'Button5',
      'Button6',
      'Button7',
    ].every((binding, index) => (
      controller[`ability${index + 1}` as InputAction] === binding
    ))
    if (usesLegacyAbilityDefaults) {
      Object.assign(controller, {
        ability1: DEFAULT_BINDINGS.controller.ability1,
        ability2: DEFAULT_BINDINGS.controller.ability2,
        ability3: DEFAULT_BINDINGS.controller.ability3,
        ability4: DEFAULT_BINDINGS.controller.ability4,
        ability5: DEFAULT_BINDINGS.controller.ability5,
        ability6: DEFAULT_BINDINGS.controller.ability6,
      })
    }
    if (savedController?.toggleSpeed === 'Button7') {
      controller.toggleSpeed = DEFAULT_BINDINGS.controller.toggleSpeed
    }
    if (savedController?.ability6 === 'Button10') {
      controller.ability6 = DEFAULT_BINDINGS.controller.ability6
    }
    if (savedController?.targetParty6 === 'Button11') {
      controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
    }
    if (
      savedController?.targetParty6 === undefined
      || savedController.targetParty6 === 'Button10'
    ) {
      controller.targetParty6 = DEFAULT_BINDINGS.controller.targetParty6
    }
    if (
      savedController?.toggleTargetGroup === undefined
      || savedController.toggleTargetGroup === 'Button6'
    ) {
      controller.toggleTargetGroup = DEFAULT_BINDINGS.controller.toggleTargetGroup
    }
    if (savedController?.toggleTouchLock === undefined) {
      controller.toggleTouchLock = DEFAULT_BINDINGS.controller.toggleTouchLock
    }
    return {
      pc: { ...DEFAULT_BINDINGS.pc, ...saved.pc },
      controller,
    }
  } catch {
    return structuredClone(DEFAULT_BINDINGS)
  }
}

function loadPreferences() {
  try {
    const saved = JSON.parse(localStorage.getItem(PREFERENCES_STORAGE_KEY) ?? '{}') as {
      controllerIconStyle?: ControllerIconStyle
      directPartyTargeting?: boolean
      combatTouchLocked?: boolean
    }
    return {
      controllerIconStyle: saved.controllerIconStyle ?? 'playstation',
      directPartyTargeting: saved.directPartyTargeting ?? false,
      combatTouchLocked: saved.combatTouchLocked ?? Capacitor.isNativePlatform(),
    }
  } catch {
    return {
      controllerIconStyle: 'playstation' as ControllerIconStyle,
      directPartyTargeting: false,
      combatTouchLocked: Capacitor.isNativePlatform(),
    }
  }
}

function isTextInput(element: Element | null): element is HTMLInputElement | HTMLTextAreaElement {
  return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement
}

function bindingGroup(action: InputAction) {
  if (action.startsWith('ability')) return 'abilities'
  if (action.startsWith('targetParty') || action === 'toggleTargetGroup') return 'direct-targeting'
  if (action === 'previousTarget' || action === 'nextTarget') return 'relative-targeting'
  if (action === 'pause') return 'pause'
  if (action === 'toggleTouchLock' || action === 'toggleSpeed') return 'system'
  return 'navigation'
}

function isVisible(element: HTMLElement) {
  if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false
  return element.getClientRects().length > 0
}

function focusableDescendants(scope: ParentNode) {
  return Array.from(
    scope.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
  ).filter(isVisible)
}

function uniqueElements(elements: HTMLElement[]) {
  return elements.filter((element, index) => elements.indexOf(element) === index)
}

function focusableElements() {
  const keyboard = document.querySelector<HTMLElement>('.controller-keyboard')
  const pauseMenu = document.querySelector<HTMLElement>('.pause-screen')
  const dialog = Array.from(
    document.querySelectorAll<HTMLElement>(
      '.result-screen, .binding-capture, .dual-startup-prompt',
    ),
  ).find(isVisible)
  const mainContent = Array.from(
    document.querySelectorAll<HTMLElement>(MAIN_CONTENT_SELECTOR),
  ).find(isVisible)
  const overlay = keyboard ?? pauseMenu ?? dialog
  if (overlay) return focusableDescendants(overlay)
  const visibleHeaders = Array.from(
    document.querySelectorAll<HTMLElement>(HEADER_CONTENT_SELECTOR),
  ).filter(isVisible)
  if (!mainContent) return visibleHeaders.length > 0 ? [] : focusableDescendants(document)
  const headerControls = visibleHeaders.flatMap(
    (header) => focusableDescendants(header),
  )
  return uniqueElements([
    ...focusableDescendants(mainContent),
    ...headerControls,
  ])
}

function rememberFocusableControl(element: HTMLElement) {
  lastControllerFocus = element
}

function focusControl(element: HTMLElement) {
  rememberFocusableControl(element)
  element.focus({ preventScroll: true })
  element.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}

function currentFocusableControl(candidates = focusableElements(), preferControllerFocus = false) {
  if (
    preferControllerFocus
    && lastControllerFocus
    && candidates.includes(lastControllerFocus)
    && isVisible(lastControllerFocus)
  ) {
    return lastControllerFocus
  }
  const active = document.activeElement
  if (active instanceof HTMLElement && candidates.includes(active)) {
    return active
  }
  if (lastControllerFocus && candidates.includes(lastControllerFocus) && isVisible(lastControllerFocus)) {
    return lastControllerFocus
  }
  return null
}

function rangeDistance(startA: number, endA: number, startB: number, endB: number) {
  if (endA < startB) return startB - endA
  if (endB < startA) return startA - endB
  return 0
}

function changeSelectOption(select: HTMLSelectElement, direction: -1 | 1) {
  const options = Array.from(select.options).filter((option) => !option.disabled)
  const currentIndex = options.findIndex((option) => option.index === select.selectedIndex)
  const nextOption = options[currentIndex + direction]
  if (!nextOption) return false
  select.selectedIndex = nextOption.index
  select.dispatchEvent(new Event('input', { bubbles: true }))
  select.dispatchEvent(new Event('change', { bubbles: true }))
  return true
}

export function focusFirstControl() {
  if (hasDedicatedGameNavigation()) return null
  const first = focusableElements()[0]
  if (first) focusControl(first)
  return first
}

function moveFocus(action: InputAction, preferControllerFocus = false) {
  const candidates = focusableElements()
  if (candidates.length === 0) return
  const current = currentFocusableControl(candidates, preferControllerFocus)
  if (!current) {
    focusFirstControl()
    return
  }

  const currentRect = current.getBoundingClientRect()
  const currentX = currentRect.left + currentRect.width / 2
  const currentY = currentRect.top + currentRect.height / 2
  const vertical = action === 'navigateUp' || action === 'navigateDown'
  const direction = action === 'navigateUp' || action === 'navigateLeft' ? -1 : 1
  const currentIndex = candidates.indexOf(current)

  const adjacent = candidates[currentIndex + direction]
  const fallbackToAdjacent = () => {
    if (adjacent) focusControl(adjacent)
  }

  const ranked = candidates
    .filter((candidate) => candidate !== current)
    .map((candidate, index) => {
      const rect = candidate.getBoundingClientRect()
      const x = rect.left + rect.width / 2
      const y = rect.top + rect.height / 2
      const primary = vertical
        ? direction > 0
          ? rect.top - currentRect.bottom
          : currentRect.top - rect.bottom
        : direction > 0
          ? rect.left - currentRect.right
          : currentRect.left - rect.right
      const secondary = vertical
        ? rangeDistance(currentRect.left, currentRect.right, rect.left, rect.right)
        : rangeDistance(currentRect.top, currentRect.bottom, rect.top, rect.bottom)
      return {
        candidate,
        index,
        primary,
        secondary,
        score: Math.max(0, primary) + secondary * 2.5 + (vertical ? Math.abs(x - currentX) : Math.abs(y - currentY)) * 0.1,
      }
    })
    .filter(({ primary }) => primary >= -4)
  const sameRowHorizontal = !vertical
    ? ranked.filter(({ secondary }) => secondary === 0)
    : ranked
  if (sameRowHorizontal.length === 0) {
    fallbackToAdjacent()
    return
  }
  sameRowHorizontal.sort((a, b) => a.score - b.score || a.secondary - b.secondary || a.index - b.index)

  const next = sameRowHorizontal[0]?.candidate
  if (!next) return
  focusControl(next)
}

function hasUiOverlay() {
  return Array.from(
    document.querySelectorAll<HTMLElement>(
      '.pause-screen, .result-screen, .binding-capture, .dual-startup-prompt, .controller-keyboard',
    ),
  ).some(isVisible)
}

function hasDedicatedGameNavigation() {
  return Array.from(
    document.querySelectorAll<HTMLElement>('[data-game-nav-active="true"]'),
  ).some(isVisible)
}

function dispatchGameAction(action: InputAction, device: InputDevice) {
  window.dispatchEvent(new CustomEvent(GAME_ACTION_EVENT, {
    detail: { action, device },
  }))
}

const BUTTON_LABELS: Record<number, string> = {
  0: 'A / Cross',
  1: 'B / Circle',
  2: 'X / Square',
  3: 'Y / Triangle',
  4: 'Left Bumper',
  5: 'Right Bumper',
  6: 'Left Trigger',
  7: 'Right Trigger',
  8: 'View / Select',
  9: 'Menu / Start',
  10: 'Left Stick',
  11: 'Right Stick',
  12: 'D-Pad Up',
  13: 'D-Pad Down',
  14: 'D-Pad Left',
  15: 'D-Pad Right',
  16: 'Home',
}

const KEY_LABELS: Record<string, string> = {
  ArrowUp: 'Up Arrow',
  ArrowDown: 'Down Arrow',
  ArrowLeft: 'Left Arrow',
  ArrowRight: 'Right Arrow',
  Enter: 'Enter',
  Escape: 'Escape',
  Space: 'Space',
}

export function bindingLabel(binding: string, iconStyle: ControllerIconStyle = 'xbox') {
  if (binding.startsWith('Button')) {
    const button = Number(binding.slice(6))
    const faceLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
      xbox: { 0: 'A', 1: 'B', 2: 'X', 3: 'Y' },
      playstation: { 0: '×', 1: '○', 2: '□', 3: '△' },
      nintendo: { 0: 'B', 1: 'A', 2: 'Y', 3: 'X' },
    }
    const shoulderLabels: Record<ControllerIconStyle, Partial<Record<number, string>>> = {
      xbox: { 4: 'LB', 5: 'RB', 6: 'LT', 7: 'RT', 8: 'View', 9: 'Start' },
      playstation: { 4: 'L1', 5: 'R1', 6: 'L2', 7: 'R2', 8: 'Share', 9: 'Options' },
      nintendo: { 4: 'L', 5: 'R', 6: 'ZL', 7: 'ZR', 8: 'Minus', 9: 'Plus' },
    }
    return faceLabels[iconStyle][button]
      ?? shoulderLabels[iconStyle][button]
      ?? BUTTON_LABELS[button]
      ?? `Button ${button}`
  }
  if (binding.startsWith('Axis')) {
    const axis = Number(binding.slice(4, -1))
    const direction = binding.endsWith('-') ? '-' : '+'
    const labels: Record<string, string> = {
      '0-': 'Left Stick Left',
      '0+': 'Left Stick Right',
      '1-': 'Left Stick Up',
      '1+': 'Left Stick Down',
      '2-': 'Right Stick Left',
      '2+': 'Right Stick Right',
      '3-': 'Right Stick Up',
      '3+': 'Right Stick Down',
    }
    return labels[`${axis}${direction}`] ?? `Axis ${axis} ${direction}`
  }
  if (KEY_LABELS[binding]) return KEY_LABELS[binding]
  if (binding.startsWith('Key') || binding.startsWith('Digit')) return binding.slice(-1)
  return binding
}

export function compactBindingLabel(
  binding: string,
  iconStyle: ControllerIconStyle = 'xbox',
) {
  const controllerLabels: Record<string, string> = {
    Button14: 'D-Pad Left',
    Button15: 'D-Pad Right',
  }
  return controllerLabels[binding] ?? bindingLabel(binding, iconStyle)
}

function gamepadTokens(gamepad: Gamepad) {
  const tokens = new Set<string>()
  gamepad.buttons.forEach((button, index) => {
    if (button.pressed || button.value > 0.65) tokens.add(`Button${index}`)
  })
  gamepad.axes.forEach((value, index) => {
    if (value < -0.65) tokens.add(`Axis${index}-`)
    if (value > 0.65) tokens.add(`Axis${index}+`)
  })
  return tokens
}

function isCombatActive() {
  return Boolean(document.querySelector('[data-combat-active="true"]'))
}

function hasContinuousMovementActive() {
  return Boolean(document.querySelector('[data-continuous-movement-active="true"]'))
}

function firstConnectedGamepad() {
  return Array.from(navigator.getGamepads?.() ?? []).find(Boolean) ?? null
}

function setInputValue(input: HTMLInputElement | HTMLTextAreaElement, nextValue: string) {
  const prototype = input instanceof HTMLTextAreaElement
    ? HTMLTextAreaElement.prototype
    : HTMLInputElement.prototype
  const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set
  setter?.call(input, nextValue)
  input.dispatchEvent(new Event('input', { bubbles: true }))
}

export function InputProvider({ children }: { children: ReactNode }) {
  const [bindings, setBindings] = useState(loadBindings)
  const [capture, setCapture] = useState<CaptureState>(null)
  const [lastDevice, setLastDevice] = useState<InputDevice>('pc')
  const [preferences, setPreferences] = useState(loadPreferences)
  const [keyboardInput, setKeyboardInput] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null)
  const [keyboardShift, setKeyboardShift] = useState(false)
  const [touchLockMessage, setTouchLockMessage] = useState('')
  const bindingsRef = useRef(bindings)
  const preferencesRef = useRef(preferences)
  const captureRef = useRef(capture)
  const keyboardInputRef = useRef(keyboardInput)
  const previousTokensRef = useRef(new Set<string>())
  const repeatRef = useRef<Record<string, number>>({})
  const gamepadConnectedRef = useRef(Capacitor.isNativePlatform())
  const touchLockMessageTimerRef = useRef(0)

  useEffect(() => {
    bindingsRef.current = bindings
    localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings))
  }, [bindings])

  useEffect(() => {
    localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))
    preferencesRef.current = preferences
  }, [preferences])

  useEffect(() => {
    captureRef.current = capture
  }, [capture])

  useEffect(() => {
    keyboardInputRef.current = keyboardInput
  }, [keyboardInput])

  const assignBinding = useCallback((device: InputDevice, action: InputAction, token: string) => {
    setBindings((current) => {
      const nextDevice = { ...current[device] }
      const previousToken = nextDevice[action]
      const collision = INPUT_ACTIONS.find(
        (candidate) => (
          candidate !== action
          && bindingGroup(candidate) === bindingGroup(action)
          && nextDevice[candidate] === token
        ),
      )
      if (collision) nextDevice[collision] = previousToken
      nextDevice[action] = token
      return { ...current, [device]: nextDevice }
    })
    setCapture(null)
  }, [])

  const closeKeyboard = useCallback(() => {
    const input = keyboardInputRef.current
    setKeyboardInput(null)
    window.requestAnimationFrame(() => input?.focus({ preventScroll: true }))
  }, [])

  const dispatchAction = useCallback((action: InputAction, device: InputDevice) => {
    const uiOverlay = hasUiOverlay()
    const combatActive = Boolean(document.querySelector('[data-combat-active="true"]'))
    const controllerUiInput = device === 'controller' && (uiOverlay || !combatActive)
    const dedicatedNavAction = action.startsWith('navigate') || action === 'confirm' || action === 'back'

    setLastDevice(device)
    document.documentElement.dataset.inputDevice = device

    if (controllerUiInput && dedicatedNavAction && hasDedicatedGameNavigation()) {
      if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
      dispatchGameAction(action, device)
      return
    }

    if (action === 'toggleTouchLock') {
      setPreferences((current) => ({
        ...current,
        combatTouchLocked: !current.combatTouchLocked,
      }))
      window.clearTimeout(touchLockMessageTimerRef.current)
      const nextLocked = !preferencesRef.current.combatTouchLocked
      setTouchLockMessage(`Combat touch ${nextLocked ? 'locked' : 'unlocked'}`)
      touchLockMessageTimerRef.current = window.setTimeout(() => {
        setTouchLockMessage('')
      }, 1400)
    } else if (action.startsWith('navigate')) {
      if (uiOverlay || !combatActive) {
        const active = currentFocusableControl(focusableElements(), controllerUiInput)
        if (
          active instanceof HTMLSelectElement
          && (action === 'navigateUp' || action === 'navigateDown')
          && changeSelectOption(active, action === 'navigateUp' ? -1 : 1)
        ) {
          if (controllerUiInput) focusControl(active)
          return
        }
        moveFocus(action, controllerUiInput)
      }
    } else if (action === 'confirm') {
      const active = currentFocusableControl(focusableElements(), controllerUiInput)
      if (isTextInput(active)) {
        setKeyboardInput(active)
        window.requestAnimationFrame(() => focusFirstControl())
      } else if (active instanceof HTMLSelectElement) {
        if (controllerUiInput) focusControl(active)
        const select = active as HTMLSelectElement & { showPicker?: () => void }
        if (select.showPicker) select.showPicker()
        else active.click()
      } else if (
        active
        && active.matches('button:not(:disabled), [role="button"]')
        && isVisible(active)
      ) {
        if (controllerUiInput) focusControl(active)
        active.click()
      } else {
        focusFirstControl()
      }
    } else if (action === 'back') {
      if (keyboardInputRef.current) {
        closeKeyboard()
      } else if (uiOverlay || !combatActive) {
        const backButton = Array.from(
          document.querySelectorAll<HTMLButtonElement>('.back-button:not(:disabled)'),
        ).find(isVisible)
        backButton?.click()
      }
    }

    dispatchGameAction(action, device)
  }, [closeKeyboard])

  const dispatchControllerToken = useCallback((token: string, repeat = false) => {
    if (captureRef.current?.device === 'controller') {
      if (!repeat) assignBinding('controller', captureRef.current.action, token)
      return
    }
    if (captureRef.current) return

    const combatActive = Boolean(
      document.querySelector('[data-combat-active="true"]'),
    )
    const uiOverlay = hasUiOverlay()
    if (
      combatActive
      && !uiOverlay
      && hasContinuousMovementActive()
      && (token.startsWith('Axis0') || token.startsWith('Axis1'))
    ) return
    const uiPriority = [
      'navigateUp',
      'navigateDown',
      'navigateLeft',
      'navigateRight',
      'confirm',
      'back',
    ] satisfies InputAction[]
    const directTargetActions = [
      'targetParty1',
      'targetParty2',
      'targetParty3',
      'targetParty4',
      'targetParty5',
      'targetParty6',
      'toggleTargetGroup',
      'toggleSpeed',
      'toggleTouchLock',
    ] satisfies InputAction[]
    const combatPriority = [
      'toggleTouchLock',
      'pause',
      'toggleSpeed',
      'ability1',
      'ability2',
      'ability3',
      'ability4',
      'ability5',
      'ability6',
      'previousTarget',
      'nextTarget',
      'navigateUp',
      'navigateDown',
      'navigateLeft',
      'navigateRight',
    ] satisfies InputAction[]
    const action = DPAD_NAV_ACTIONS[token] && (!combatActive || uiOverlay)
      ? DPAD_NAV_ACTIONS[token]
      : uiOverlay
      ? uiPriority.find((candidate) => bindingsRef.current.controller[candidate] === token)
      : combatActive && preferencesRef.current.directPartyTargeting
      ? [...directTargetActions, ...combatPriority].find(
          (candidate) => bindingsRef.current.controller[candidate] === token,
        )
      : combatActive && DPAD_NAV_ACTIONS[token]
        ? DPAD_NAV_ACTIONS[token]
      : !combatActive && DPAD_NAV_ACTIONS[token]
      ? DPAD_NAV_ACTIONS[token]
      : (combatActive ? combatPriority : INPUT_ACTIONS).find(
          (candidate) => bindingsRef.current.controller[candidate] === token,
        )
    if (!action) return
    if (repeat && !action.startsWith('navigate')) return
    dispatchAction(action, 'controller')
  }, [assignBinding, dispatchAction])

  useEffect(() => {
    const onKeyDown = (event: KeyboardEvent) => {
      const active = document.activeElement
      if (captureRef.current?.device === 'pc') {
        event.preventDefault()
        if (event.code === 'Escape') {
          setCapture(null)
          return
        }
        assignBinding('pc', captureRef.current.action, event.code)
        return
      }
      if (captureRef.current || (isTextInput(active) && !keyboardInputRef.current)) return
      const action = INPUT_ACTIONS.find(
        (candidate) => bindingsRef.current.pc[candidate] === event.code,
      )
      if (!action) return
      event.preventDefault()
      if (event.repeat && !action.startsWith('navigate')) return
      dispatchAction(action, 'pc')
    }
    window.addEventListener('keydown', onKeyDown)
    return () => window.removeEventListener('keydown', onKeyDown)
  }, [assignBinding, dispatchAction])

  useEffect(() => {
    const onFocusIn = (event: FocusEvent) => {
      const target = event.target
      if (!(target instanceof HTMLElement)) return
      if (!target.matches(FOCUSABLE_SELECTOR) || !isVisible(target)) return
      if (document.documentElement.dataset.inputDevice !== 'controller') return
      rememberFocusableControl(target)
    }
    const onPointerDown = (event: PointerEvent) => {
      if (event.pointerType !== 'touch') {
        document.documentElement.dataset.inputDevice = 'pc'
      }
      const target = event.target
      if (!(target instanceof Element)) return
      const control = target.closest<HTMLElement>(FOCUSABLE_SELECTOR)
      if (!control || !isVisible(control)) return
      if (event.pointerType !== 'touch') rememberFocusableControl(control)
    }
    document.addEventListener('focusin', onFocusIn)
    document.addEventListener('pointerdown', onPointerDown, { capture: true })
    return () => {
      document.removeEventListener('focusin', onFocusIn)
      document.removeEventListener('pointerdown', onPointerDown, { capture: true })
    }
  }, [])

  useEffect(() => {
    const listener = (event: Event) => {
      const detail = (event as CustomEvent<{ token: string; repeat?: boolean }>).detail
      dispatchControllerToken(detail.token, Boolean(detail.repeat))
    }
    window.addEventListener(NATIVE_CONTROLLER_EVENT, listener)
    return () => window.removeEventListener(NATIVE_CONTROLLER_EVENT, listener)
  }, [dispatchControllerToken])

  useEffect(() => () => {
    window.clearTimeout(touchLockMessageTimerRef.current)
  }, [])

  useEffect(() => {
    let focusFrame = 0
    const ensureFocus = () => {
      focusFrame = 0
      const combatActive = document.querySelector('[data-combat-active="true"]')
      if (combatActive) return
      if (hasDedicatedGameNavigation()) return
      const candidates = focusableElements()
      const activeControl = currentFocusableControl(candidates)
      if (
        (!activeControl || document.activeElement === document.body)
        && !keyboardInputRef.current
        && !captureRef.current
      ) {
        if (activeControl) {
          focusControl(activeControl)
        } else {
          focusFirstControl()
        }
      }
    }
    const scheduleEnsureFocus = () => {
      if (focusFrame) return
      focusFrame = window.requestAnimationFrame(ensureFocus)
    }
    const observer = new MutationObserver(() => {
      scheduleEnsureFocus()
    })
    observer.observe(document.getElementById('root') ?? document.body, {
      attributes: true,
      attributeFilter: ['aria-hidden', 'class', 'disabled', 'hidden'],
      childList: true,
      subtree: true,
    })
    scheduleEnsureFocus()
    return () => {
      if (focusFrame) window.cancelAnimationFrame(focusFrame)
      observer.disconnect()
    }
  }, [])

  useEffect(() => {
    let timer = 0
    if (Capacitor.isNativePlatform() && capture?.device !== 'controller') {
      previousTokensRef.current = new Set()
      repeatRef.current = {}
      return undefined
    }
    const nextDelay = () => {
      if (Capacitor.isNativePlatform()) {
        return GAMEPAD_COMBAT_POLL_MS
      }
      if (!Capacitor.isNativePlatform() && !gamepadConnectedRef.current) {
        return GAMEPAD_BROWSER_DISCONNECTED_POLL_MS
      }
      return isCombatActive() ? GAMEPAD_COMBAT_POLL_MS : GAMEPAD_MENU_POLL_MS
    }
    const clearControllerState = () => {
      previousTokensRef.current = new Set()
      repeatRef.current = {}
    }
    const poll = () => {
      const time = performance.now()
      const gamepad = firstConnectedGamepad()
      gamepadConnectedRef.current = Capacitor.isNativePlatform() || Boolean(gamepad)
      const currentTokens = gamepad ? gamepadTokens(gamepad) : new Set<string>()
      const previousTokens = previousTokensRef.current

      currentTokens.forEach((token) => {
        const pressed = !previousTokens.has(token)
        if (pressed && captureRef.current?.device === 'controller') {
          assignBinding('controller', captureRef.current.action, token)
          return
        }
        if (captureRef.current) return
        const action = INPUT_ACTIONS.find(
          (candidate) => bindingsRef.current.controller[candidate] === token,
        )
        const canRepeat = Boolean(action?.startsWith('navigate') || DPAD_NAV_ACTIONS[token])
        const nextRepeat = repeatRef.current[token] ?? 0
        if (pressed || (canRepeat && time >= nextRepeat)) {
          dispatchControllerToken(token, !pressed)
          repeatRef.current[token] = time + (pressed ? CONTROLLER_REPEAT_INITIAL_MS : CONTROLLER_REPEAT_MS)
        }
      })

      Object.keys(repeatRef.current).forEach((token) => {
        if (!currentTokens.has(token)) delete repeatRef.current[token]
      })
      previousTokensRef.current = currentTokens
      if (!gamepad) clearControllerState()
      timer = window.setTimeout(poll, nextDelay())
    }
    const onGamepadConnected = () => {
      gamepadConnectedRef.current = true
    }
    const onGamepadDisconnected = () => {
      gamepadConnectedRef.current = Boolean(firstConnectedGamepad())
      if (!gamepadConnectedRef.current) clearControllerState()
    }
    window.addEventListener('gamepadconnected', onGamepadConnected)
    window.addEventListener('gamepaddisconnected', onGamepadDisconnected)
    timer = window.setTimeout(poll, 0)
    return () => {
      window.clearTimeout(timer)
      window.removeEventListener('gamepadconnected', onGamepadConnected)
      window.removeEventListener('gamepaddisconnected', onGamepadDisconnected)
    }
  }, [assignBinding, capture, dispatchControllerToken])

  useEffect(() => {
    const shouldBlockTouch = () => (
      Capacitor.isNativePlatform()
      && preferencesRef.current.combatTouchLocked
      && isCombatActive()
      && !hasUiOverlay()
    )
    const blockTouch = (event: Event) => {
      if (
        typeof PointerEvent !== 'undefined'
        && event instanceof PointerEvent
        && event.pointerType !== 'touch'
      ) return
      if (!shouldBlockTouch()) return
      event.preventDefault()
      event.stopImmediatePropagation()
    }
    const options = { capture: true, passive: false }
    document.addEventListener('pointerdown', blockTouch, options)
    document.addEventListener('pointerup', blockTouch, options)
    document.addEventListener('touchstart', blockTouch, options)
    document.addEventListener('touchmove', blockTouch, options)
    document.addEventListener('touchend', blockTouch, options)
    return () => {
      document.removeEventListener('pointerdown', blockTouch, options)
      document.removeEventListener('pointerup', blockTouch, options)
      document.removeEventListener('touchstart', blockTouch, options)
      document.removeEventListener('touchmove', blockTouch, options)
      document.removeEventListener('touchend', blockTouch, options)
    }
  }, [])

  const contextValue = useMemo<InputContextValue>(() => ({
    bindings,
    capture,
    lastDevice,
    controllerIconStyle: preferences.controllerIconStyle,
    directPartyTargeting: preferences.directPartyTargeting,
    combatTouchLocked: preferences.combatTouchLocked,
    beginCapture: (device, action) => setCapture({ device, action }),
    cancelCapture: () => setCapture(null),
    resetBindings: (device) => setBindings((current) => ({
      ...current,
      [device]: { ...DEFAULT_BINDINGS[device] },
    })),
    setControllerIconStyle: (controllerIconStyle) => setPreferences((current) => ({
      ...current,
      controllerIconStyle,
    })),
    setDirectPartyTargeting: (directPartyTargeting) => setPreferences((current) => ({
      ...current,
      directPartyTargeting,
    })),
    setCombatTouchLocked: (combatTouchLocked) => setPreferences((current) => ({
      ...current,
      combatTouchLocked,
    })),
  }), [bindings, capture, lastDevice, preferences])

  function typeKeyboardKey(key: string) {
    if (!keyboardInput) return
    const start = keyboardInput.selectionStart ?? keyboardInput.value.length
    const end = keyboardInput.selectionEnd ?? start
    if (key === 'backspace') {
      const from = start === end ? Math.max(0, start - 1) : start
      setInputValue(keyboardInput, keyboardInput.value.slice(0, from) + keyboardInput.value.slice(end))
      window.requestAnimationFrame(() => keyboardInput.setSelectionRange(from, from))
      return
    }
    const value = key === 'space' ? ' ' : keyboardShift ? key.toUpperCase() : key
    const next = keyboardInput.value.slice(0, start) + value + keyboardInput.value.slice(end)
    const maxLength = keyboardInput.maxLength > 0 ? keyboardInput.maxLength : Number.POSITIVE_INFINITY
    const limited = next.slice(0, maxLength)
    setInputValue(keyboardInput, limited)
    const cursor = Math.min(start + value.length, limited.length)
    window.requestAnimationFrame(() => keyboardInput.setSelectionRange(cursor, cursor))
  }

  const keyboardKeys = [
    ...'1234567890',
    ...'qwertyuiop',
    ...'asdfghjkl',
    ...'zxcvbnm',
    '_', '-', '@', '.', '!', '?', '#', '$',
  ]

  return (
    <InputContext.Provider value={contextValue}>
      {children}
      {keyboardInput && (
        <div className="controller-keyboard-backdrop" role="presentation">
          <section className="controller-keyboard" aria-label="On-screen keyboard">
            <div className="controller-keyboard-heading">
              <div>
                <p className="eyebrow">Controller Keyboard</p>
                <strong>{keyboardInput.value || 'Enter text'}</strong>
              </div>
              <button onClick={closeKeyboard} type="button">Done</button>
            </div>
            <div className="controller-keyboard-grid">
              {keyboardKeys.map((key) => (
                <button key={key} onClick={() => typeKeyboardKey(key)} type="button">
                  {keyboardShift ? key.toUpperCase() : key}
                </button>
              ))}
            </div>
            <div className="controller-keyboard-actions">
              <button
                className={keyboardShift ? 'active' : ''}
                onClick={() => setKeyboardShift((value) => !value)}
                type="button"
              >
                Shift
              </button>
              <button onClick={() => typeKeyboardKey('space')} type="button">Space</button>
              <button onClick={() => typeKeyboardKey('backspace')} type="button">Backspace</button>
              <button onClick={closeKeyboard} type="button">Done</button>
            </div>
          </section>
        </div>
      )}
      {touchLockMessage && (
        <div className="combat-touch-lock-status" aria-live="polite">
          {touchLockMessage}
        </div>
      )}
    </InputContext.Provider>
  )
}

function normalizedVector(x: number, y: number): MovementVector {
  const magnitude = Math.hypot(x, y)
  if (magnitude <= 1) return { x, y }
  return { x: x / magnitude, y: y / magnitude }
}

function applyStickDeadzone(x: number, y: number, deadzone = 0.18): MovementVector {
  const magnitude = Math.hypot(x, y)
  if (magnitude < deadzone) return { x: 0, y: 0 }
  const scaled = Math.min(1, (magnitude - deadzone) / (1 - deadzone))
  return {
    x: (x / magnitude) * scaled,
    y: (y / magnitude) * scaled,
  }
}

export function useMovementVectorRef(enabled = true) {
  const movementRef = useRef<MovementVector>({ x: 0, y: 0 })

  useEffect(() => {
    if (!enabled) {
      movementRef.current = { x: 0, y: 0 }
      return undefined
    }

    let frame = 0
    const pressedKeys = new Set<string>()
    let nativeStick: MovementVector = { x: 0, y: 0 }
    let nativeStickUpdatedAt = 0
    const keyboardVector = () => normalizedVector(
      (pressedKeys.has('KeyD') ? 1 : 0) - (pressedKeys.has('KeyA') ? 1 : 0),
      (pressedKeys.has('KeyS') ? 1 : 0) - (pressedKeys.has('KeyW') ? 1 : 0),
    )
    const updateMovement = () => {
      const gamepad = firstConnectedGamepad()
      const stick = gamepad ? applyStickDeadzone(gamepad.axes[0] ?? 0, gamepad.axes[1] ?? 0) : { x: 0, y: 0 }
      const native = performance.now() - nativeStickUpdatedAt <= NATIVE_STICK_TIMEOUT_MS
        ? applyStickDeadzone(nativeStick.x, nativeStick.y)
        : { x: 0, y: 0 }
      const keyboard = keyboardVector()
      movementRef.current = Math.hypot(native.x, native.y) > 0
        ? native
        : Math.hypot(stick.x, stick.y) > 0
          ? stick
          : keyboard
      frame = window.requestAnimationFrame(updateMovement)
    }
    const onNativeMotion = (event: Event) => {
      const detail = (event as CustomEvent<Partial<MovementVector>>).detail
      nativeStick = {
        x: Number(detail.x) || 0,
        y: Number(detail.y) || 0,
      }
      nativeStickUpdatedAt = performance.now()
      document.documentElement.dataset.inputDevice = 'controller'
    }
    const onKeyDown = (event: KeyboardEvent) => {
      if (isTextInput(document.activeElement)) return
      if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
      pressedKeys.add(event.code)
      document.documentElement.dataset.inputDevice = 'pc'
      event.preventDefault()
    }
    const onKeyUp = (event: KeyboardEvent) => {
      if (!['KeyW', 'KeyA', 'KeyS', 'KeyD'].includes(event.code)) return
      pressedKeys.delete(event.code)
      event.preventDefault()
    }

    window.addEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
    window.addEventListener('keydown', onKeyDown)
    window.addEventListener('keyup', onKeyUp)
    frame = window.requestAnimationFrame(updateMovement)

    return () => {
      window.cancelAnimationFrame(frame)
      window.removeEventListener(NATIVE_CONTROLLER_MOTION_EVENT, onNativeMotion)
      window.removeEventListener('keydown', onKeyDown)
      window.removeEventListener('keyup', onKeyUp)
      movementRef.current = { x: 0, y: 0 }
    }
  }, [enabled])

  return movementRef
}

export function useInput() {
  const context = useContext(InputContext)
  if (!context) throw new Error('useInput must be used inside InputProvider')
  return context
}

export function useGameAction(
  handler: (action: InputAction, device: InputDevice) => void,
) {
  const handlerRef = useRef(handler)
  useEffect(() => {
    handlerRef.current = handler
  }, [handler])
  useEffect(() => {
    const listener = (event: Event) => {
      const detail = (event as CustomEvent<{ action: InputAction; device: InputDevice }>).detail
      handlerRef.current(detail.action, detail.device)
    }
    window.addEventListener(GAME_ACTION_EVENT, listener)
    return () => window.removeEventListener(GAME_ACTION_EVENT, listener)
  }, [])
}

export function dispatchExternalGameAction(
  action: InputAction,
  device: InputDevice,
) {
  dispatchGameAction(action, device)
}
