/**
 * --------------------------------------------------------------------------
 * Bootstrap otp-input.ts
 * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
 * --------------------------------------------------------------------------
 */

import BaseComponent from './base-component.js'
import EventHandler, { type BootstrapEvent } from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'

/**
 * Constants
 */

const NAME = 'otpInput'
const DATA_KEY = 'bs.otpInput'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'

const EVENT_COMPLETE = `complete${EVENT_KEY}`
const EVENT_INPUT = `input${EVENT_KEY}`
const EVENT_DOMCONTENT_LOADED = `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`

const SELECTOR_DATA_OTP = '[data-bs-otp]'
const SELECTOR_INPUT = 'input'

const CLASS_NAME_INPUT = 'otp-input'
const CLASS_NAME_RENDERED = 'otp-rendered'
const CLASS_NAME_SLOTS = 'otp-slots'
const CLASS_NAME_SLOT = 'otp-slot'
const CLASS_NAME_SLOT_FILLED = 'otp-slot-filled'
const CLASS_NAME_SLOT_ACTIVE = 'otp-slot-active'
const CLASS_NAME_SEPARATOR = 'otp-separator'

const MASK_CHARACTER = '•'

// Per-type input mode, validation pattern, and a filter that strips disallowed characters
const TYPES = {
  numeric: { inputmode: 'numeric', pattern: '[0-9]*', filter: /[^0-9]/g },
  alphanumeric: { inputmode: 'text', pattern: '[A-Za-z0-9]*', filter: /[^A-Za-z0-9]/g },
  alpha: { inputmode: 'text', pattern: '[A-Za-z]*', filter: /[^A-Za-z]/g }
}

type OtpInputConfig = {
  groups: number[] | null
  length: number | null
  mask: boolean
  separator: string
  type: string
}

const Default: OtpInputConfig = {
  groups: null,
  length: null,
  mask: false,
  separator: '·',
  type: 'numeric'
}

const DefaultType = {
  groups: '(array|null)',
  length: '(number|null)',
  mask: 'boolean',
  separator: 'string',
  type: 'string'
}

/**
 * Class definition
 */

class OtpInput extends BaseComponent {
  protected declare _config: OtpInputConfig
  protected declare _input: HTMLInputElement
  protected declare _type: { inputmode: string, pattern: string, filter: RegExp }
  protected declare _length: number
  protected declare _slots: HTMLElement[]
  protected declare _pointerActive: boolean
  protected declare _pointerIndex: number
  protected declare _slotsContainer: HTMLElement
  protected declare _inputListeners: Record<string, (event: BootstrapEvent) => void>
  protected declare _onSelectionChange: () => void

  constructor(element?: string | Element | null, config?: Partial<OtpInputConfig> | null) {
    super(element, config)

    const input = SelectorEngine.findOne<HTMLInputElement>(SELECTOR_INPUT, this._element)
    if (!input) {
      return
    }

    this._input = input

    this._type = TYPES[this._config.type as keyof typeof TYPES] || TYPES.numeric
    this._length = this._resolveLength()
    this._slots = []
    // Tracks whether focus was triggered by a click so we can respect the
    // clicked slot instead of jumping to the first empty one
    this._pointerActive = false
    // Slot index from the most recent tap, applied once focus settles
    this._pointerIndex = 0

    this._setupInput()
    this._renderSlots()
    this._addEventListeners()
    this._render()
  }

  // Getters
  static override get Default(): OtpInputConfig {
    return Default
  }

  static override get DefaultType(): Record<string, string> {
    return DefaultType
  }

  static override get NAME(): string {
    return NAME
  }

  // Public
  getValue(): string {
    return this._input.value
  }

  setValue(value: string | number): void {
    this._input.value = this._sanitize(String(value))
    this._render()
    this._checkComplete()
  }

  clear(): void {
    this._input.value = ''
    this._render()
    this._input.focus()
  }

  focus(): void {
    this._input.focus()
    // Select the first empty slot (or the last one when the value is full)
    this._selectSlot(this._firstEmptyIndex())
    this._render()
  }

  override dispose(): void {
    for (const [type, listener] of Object.entries(this._inputListeners ?? {})) {
      EventHandler.off(this._input, type, listener)
    }

    EventHandler.off(document, 'selectionchange', this._onSelectionChange)

    this._slotsContainer?.remove()
    this._element.classList.remove(CLASS_NAME_RENDERED)
    super.dispose()
  }

  // Private
  protected _resolveLength(): number {
    if (this._config.length) {
      return this._config.length
    }

    const maxLength = Number.parseInt(this._input.getAttribute('maxlength')!, 10)
    return Number.isNaN(maxLength) || maxLength < 1 ? 6 : maxLength
  }

  protected _setupInput(): void {
    const input = this._input

    // A single text field backs the whole control so screen readers, password
    // managers, and SMS autofill treat it like any other input.
    if (input.type === 'number' || input.type === 'password') {
      input.type = 'text'
    }

    input.classList.add(CLASS_NAME_INPUT)
    input.setAttribute('maxlength', String(this._length))
    input.setAttribute('inputmode', this._type.inputmode)
    input.setAttribute('pattern', this._type.pattern)

    if (!input.getAttribute('autocomplete')) {
      input.setAttribute('autocomplete', 'one-time-code')
    }

    // Filter any pre-filled value through the configured type
    if (input.value) {
      input.value = this._sanitize(input.value)
    }
  }

  protected _renderSlots(): void {
    const container = document.createElement('div')
    container.className = CLASS_NAME_SLOTS
    container.setAttribute('aria-hidden', 'true')

    const { groups } = this._config
    let groupIndex = 0
    let inGroup = 0

    for (let i = 0; i < this._length; i++) {
      const slot = document.createElement('div')
      slot.className = CLASS_NAME_SLOT
      container.append(slot)
      this._slots.push(slot)

      // Insert a visual separator between configured groups
      if (Array.isArray(groups) && groups.length > 0) {
        inGroup++
        if (inGroup === groups[groupIndex] && i < this._length - 1) {
          const separator = document.createElement('div')
          separator.className = CLASS_NAME_SEPARATOR
          separator.textContent = this._config.separator
          container.append(separator)
          groupIndex = Math.min(groupIndex + 1, groups.length - 1)
          inGroup = 0
        }
      }
    }

    this._slotsContainer = container
    this._element.append(container)
    this._element.classList.add(CLASS_NAME_RENDERED)
  }

  protected _addEventListeners(): void {
    // Listeners are attached with bare event names (not namespaced) because
    // `input`, `beforeinput`, `paste`, and `selectionchange` are not in
    // EventHandler's native-events list. Keep references to remove on dispose.
    this._inputListeners = {
      input: () => this._handleInput(),
      beforeinput: event => this._handleBeforeInput(event),
      paste: event => this._handlePaste(event),
      pointerdown: event => this._handlePointerDown(event)
    }
    this._inputListeners.focus = () => {
      if (this._pointerActive) {
        // A tap focused the input natively; position the caret on the clicked
        // slot now that focus has settled (doing this before native focus would
        // make iOS/iPadOS raise then immediately dismiss the keyboard)
        this._pointerActive = false
        this._selectSlot(this._pointerIndex)
        this._render()
        return
      }

      // Keyboard (Tab) focus lands on the first empty slot
      this._selectSlot(this._firstEmptyIndex())
      this._render()
    }

    const onSync = () => this._render()
    this._onSelectionChange = () => {
      if (document.activeElement === this._input) {
        this._render()
      }
    }

    for (const type of ['blur', 'keyup', 'select']) {
      this._inputListeners[type] = onSync
    }

    for (const [type, listener] of Object.entries(this._inputListeners)) {
      EventHandler.on(this._input, type, listener)
    }

    EventHandler.on(document, 'selectionchange', this._onSelectionChange)
  }

  // Bulk path: SMS autofill or a programmatic value change lands here as a
  // single multi-character `input` event. Single keystrokes are handled by
  // `_handleBeforeInput` (overwrite semantics) and never reach this method.
  protected _handleInput(): void {
    const sanitized = this._sanitize(this._input.value)
    if (sanitized !== this._input.value) {
      this._input.value = sanitized
    }

    // Place the caret on the first empty slot after a paste/autofill
    if (document.activeElement === this._input) {
      this._selectSlot(this._firstEmptyIndex())
    }

    this._afterValueChange()
  }

  protected _handlePaste(event: BootstrapEvent): void {
    const pastedValue = event.clipboardData?.getData('text')
    if (!pastedValue || this._input.readOnly) {
      return
    }

    const sanitized = this._sanitize(pastedValue)
    // Sanitize before applying the length limit. The browser applies maxlength
    // to raw clipboard text before the input event, which can discard valid
    // characters that follow separators or whitespace.
    let start = this._input.selectionStart ?? this._input.value.length
    let end = this._input.selectionEnd ?? start
    if (sanitized.length === this._length) {
      start = 0
      end = this._input.value.length
    }

    const inserted = sanitized.slice(0, this._length - this._input.value.length + end - start)

    event.preventDefault()
    if (!inserted) {
      return
    }

    this._input.setRangeText(inserted, start, end, 'end')
    this._afterValueChange()
  }

  // Intercept single-character typing and backspace so each slot is overwritten
  // in place rather than inserting and shifting the rest of the value. Autofill
  // and IME composition fall through to `_handleInput`.
  protected _handleBeforeInput(event: BootstrapEvent): void {
    const { inputType, data } = event

    if (inputType === 'insertText' && data && data.length === 1) {
      event.preventDefault()

      const char = this._sanitize(data)
      if (!char) {
        return
      }

      const index = Math.min(this._input.selectionStart ?? 0, this._length - 1)
      const chars = [...this._input.value]
      chars[index] = char
      this._input.value = chars.join('').slice(0, this._length)

      this._selectSlot(index + 1)
      this._afterValueChange()
      return
    }

    if (inputType === 'deleteContentBackward') {
      event.preventDefault()

      const start = this._input.selectionStart ?? 0
      const end = this._input.selectionEnd ?? start
      const chars = [...this._input.value]

      if (end > start) {
        // A filled slot is selected: clear it and keep the caret in place
        chars.splice(start, end - start)
        this._input.value = chars.join('')
        this._selectSlot(start)
      } else if (start > 0) {
        // Collapsed caret: remove the previous character and step back
        chars.splice(start - 1, 1)
        this._input.value = chars.join('')
        this._selectSlot(start - 1)
      }

      this._afterValueChange()
    }
  }

  protected _handlePointerDown(event: BootstrapEvent): void {
    const index = this._slotIndexFromPoint(event.clientX)
    if (index === null) {
      return
    }

    // Don't let the caret land past the first empty slot
    const target = Math.min(index, this._firstEmptyIndex())

    if (document.activeElement === this._input) {
      // Already focused (keyboard is up): take over caret placement from the
      // browser. Safe to preventDefault here — it won't dismiss the keyboard.
      event.preventDefault()
      this._selectSlot(target)
      this._render()
      return
    }

    // Not yet focused: let the browser focus the input natively so the
    // on-screen keyboard is raised by the user's tap. Position the caret in the
    // focus handler once focus settles.
    this._pointerActive = true
    this._pointerIndex = target
  }

  // Map a viewport x-coordinate to the slot under it, clamped to the last slot
  protected _slotIndexFromPoint(x: number): number | null {
    for (const [index, slot] of this._slots.entries()) {
      if (x <= slot.getBoundingClientRect().right || index === this._slots.length - 1) {
        return index
      }
    }

    return null
  }

  protected _afterValueChange(): void {
    this._render()
    EventHandler.trigger(this._element, EVENT_INPUT, { value: this._input.value })
    this._checkComplete()
  }

  protected _firstEmptyIndex(): number {
    return Math.min(this._input.value.length, this._length - 1)
  }

  // Represent the active slot as a selection: a filled slot is selected so the
  // next keystroke overwrites it; an empty slot gets a collapsed caret.
  protected _selectSlot(index: number): void {
    const clamped = Math.max(0, Math.min(index, this._length - 1))
    const end = clamped < this._input.value.length ? clamped + 1 : clamped
    this._input.setSelectionRange(clamped, end)
  }

  protected _sanitize(value: string): string {
    return value.replace(this._type.filter, '').slice(0, this._length)
  }

  protected _render(): void {
    const { value } = this._input
    const isFocused = document.activeElement === this._input
    // The active slot follows the caret, clamped to the last slot when the value is full
    const caret = Math.min(this._input.selectionStart ?? value.length, this._length - 1)

    for (const [index, slot] of this._slots.entries()) {
      const char = value[index] ?? ''
      slot.textContent = char && this._config.mask ? MASK_CHARACTER : char
      slot.classList.toggle(CLASS_NAME_SLOT_FILLED, Boolean(char))
      slot.classList.toggle(CLASS_NAME_SLOT_ACTIVE, isFocused && index === caret)
    }
  }

  protected _checkComplete(): void {
    const { value } = this._input
    if (value.length === this._length) {
      EventHandler.trigger(this._element, EVENT_COMPLETE, { value })
    }
  }
}

/**
 * Data API implementation
 */

EventHandler.on(document, EVENT_DOMCONTENT_LOADED, () => {
  for (const element of SelectorEngine.find(SELECTOR_DATA_OTP)) {
    OtpInput.getOrCreateInstance(element)
  }
})

export default OtpInput
export type { OtpInputConfig }
