/**
 * ColorPicker - Color selection component with canvas-based pickers
 * @countermeasure/web-components/form/color-picker
 */

import { type ColorPickerConfig } from './types'
import { resolveContainer, uniqueId } from '../../primitives/utils'
import { announce } from '../../utils/a11y'

export type { ColorPickerConfig } from './types'

/** CSS color name lookup for accessibility announcements */
const COLOR_NAMES: { r: number; g: number; b: number; name: string }[] = [
  { r: 255, g: 0, b: 0, name: 'Red' },
  { r: 0, g: 255, b: 0, name: 'Lime' },
  { r: 0, g: 0, b: 255, name: 'Blue' },
  { r: 255, g: 255, b: 0, name: 'Yellow' },
  { r: 0, g: 255, b: 255, name: 'Cyan' },
  { r: 255, g: 0, b: 255, name: 'Magenta' },
  { r: 255, g: 255, b: 255, name: 'White' },
  { r: 0, g: 0, b: 0, name: 'Black' },
  { r: 128, g: 128, b: 128, name: 'Gray' },
  { r: 255, g: 165, b: 0, name: 'Orange' },
  { r: 128, g: 0, b: 128, name: 'Purple' },
  { r: 165, g: 42, b: 42, name: 'Brown' },
  { r: 255, g: 192, b: 203, name: 'Pink' },
  { r: 0, g: 128, b: 0, name: 'Green' },
  { r: 0, g: 0, b: 128, name: 'Navy' },
  { r: 64, g: 224, b: 208, name: 'Turquoise' },
]

/** Find the closest named color */
function getClosestColorName(r: number, g: number, b: number): string {
  let closest = 'Custom color'
  let minDist = Infinity

  for (const color of COLOR_NAMES) {
    const dist = Math.sqrt(
      Math.pow(r - color.r, 2) + Math.pow(g - color.g, 2) + Math.pow(b - color.b, 2)
    )
    if (dist < minDist) {
      minDist = dist
      closest = color.name
    }
  }

  // If distance is too far, call it a custom color
  if (minDist > 80) {
    return 'Custom color'
  }

  return closest
}

/** Clamp a number to the range [min, max] */
function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

/** Internal RGBA representation */
interface RGBA {
  r: number
  g: number
  b: number
  a: number
}

/** Internal HSLA representation */
interface HSLA {
  h: number
  s: number
  l: number
  a: number
}

/** Convert hex string to RGBA */
function hexToRgba(hex: string): RGBA {
  let h = hex.replace(/^#/, '')
  if (h.length === 3 || h.length === 4) {
    h = h
      .split('')
      .map(c => c + c)
      .join('')
  }
  const r = parseInt(h.slice(0, 2), 16)
  const g = parseInt(h.slice(2, 4), 16)
  const b = parseInt(h.slice(4, 6), 16)
  const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1
  return {
    r: isNaN(r) ? 0 : r,
    g: isNaN(g) ? 0 : g,
    b: isNaN(b) ? 0 : b,
    a: isNaN(a) ? 1 : a,
  }
}

/** Convert RGBA to hex string */
function rgbaToHex(rgba: RGBA, includeAlpha: boolean): string {
  const r = clamp(Math.round(rgba.r), 0, 255).toString(16).padStart(2, '0')
  const g = clamp(Math.round(rgba.g), 0, 255).toString(16).padStart(2, '0')
  const b = clamp(Math.round(rgba.b), 0, 255).toString(16).padStart(2, '0')
  if (includeAlpha && rgba.a < 1) {
    const a = clamp(Math.round(rgba.a * 255), 0, 255)
      .toString(16)
      .padStart(2, '0')
    return `#${r}${g}${b}${a}`
  }
  return `#${r}${g}${b}`
}

/** Convert RGBA to HSLA */
function rgbaToHsla(rgba: RGBA): HSLA {
  const r = rgba.r / 255
  const g = rgba.g / 255
  const b = rgba.b / 255

  const max = Math.max(r, g, b)
  const min = Math.min(r, g, b)
  const l = (max + min) / 2
  let h = 0
  let s = 0

  if (max !== min) {
    const d = max - min
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    if (max === r) {
      h = ((g - b) / d + (g < b ? 6 : 0)) / 6
    } else if (max === g) {
      h = ((b - r) / d + 2) / 6
    } else {
      h = ((r - g) / d + 4) / 6
    }
  }

  return { h: h * 360, s: s * 100, l: l * 100, a: rgba.a }
}

/** Convert HSV (h 0-360, s 0-1, v 0-1) to RGBA */
function hsvToRgba(h: number, s: number, v: number, a: number): RGBA {
  const i = Math.floor(h / 60) % 6
  const f = h / 60 - Math.floor(h / 60)
  const p = v * (1 - s)
  const q = v * (1 - f * s)
  const t = v * (1 - (1 - f) * s)

  let r: number
  let g: number
  let b: number

  switch (i) {
    case 0:
      r = v
      g = t
      b = p
      break
    case 1:
      r = q
      g = v
      b = p
      break
    case 2:
      r = p
      g = v
      b = t
      break
    case 3:
      r = p
      g = q
      b = v
      break
    case 4:
      r = t
      g = p
      b = v
      break
    default:
      r = v
      g = p
      b = q
      break
  }

  return {
    r: Math.round(r * 255),
    g: Math.round(g * 255),
    b: Math.round(b * 255),
    a,
  }
}

/** Convert RGBA to HSV (returns h 0-360, s 0-1, v 0-1) */
function rgbaToHsv(rgba: RGBA): { h: number; s: number; v: number } {
  const r = rgba.r / 255
  const g = rgba.g / 255
  const b = rgba.b / 255
  const max = Math.max(r, g, b)
  const min = Math.min(r, g, b)
  const d = max - min
  let h = 0

  if (d !== 0) {
    if (max === r) {
      h = ((g - b) / d + (g < b ? 6 : 0)) * 60
    } else if (max === g) {
      h = ((b - r) / d + 2) * 60
    } else {
      h = ((r - g) / d + 4) * 60
    }
  }

  const s = max === 0 ? 0 : d / max
  return { h, s, v: max }
}

/** Format an RGBA value as an rgb/rgba string */
function formatRgbString(rgba: RGBA): string {
  if (rgba.a < 1) {
    return `rgba(${String(Math.round(rgba.r))}, ${String(Math.round(rgba.g))}, ${String(Math.round(rgba.b))}, ${String(Math.round(rgba.a * 100) / 100)})`
  }
  return `rgb(${String(Math.round(rgba.r))}, ${String(Math.round(rgba.g))}, ${String(Math.round(rgba.b))})`
}

/** Format an HSLA value as an hsl/hsla string */
function formatHslString(hsla: HSLA): string {
  const h = Math.round(hsla.h)
  const s = Math.round(hsla.s)
  const l = Math.round(hsla.l)
  if (hsla.a < 1) {
    return `hsla(${String(h)}, ${String(s)}%, ${String(l)}%, ${String(Math.round(hsla.a * 100) / 100)})`
  }
  return `hsl(${String(h)}, ${String(s)}%, ${String(l)}%)`
}

export class ColorPicker {
  private readonly container: HTMLElement
  private readonly config: ColorPickerConfig
  private readonly id: string

  private rgba: RGBA = { r: 0, g: 0, b: 0, a: 1 }
  private hue = 0
  private saturation = 0
  private brightness = 0

  private swatchEl: HTMLElement | null = null
  private panelEl: HTMLElement | null = null
  private satCanvas: HTMLCanvasElement | null = null
  private hueCanvas: HTMLCanvasElement | null = null
  private alphaCanvas: HTMLCanvasElement | null = null
  private hexInput: HTMLInputElement | null = null
  private rInput: HTMLInputElement | null = null
  private gInput: HTMLInputElement | null = null
  private bInput: HTMLInputElement | null = null
  private isOpen = false

  private readonly boundOnDocClick: (e: MouseEvent) => void
  private activeDrag: 'saturation' | 'hue' | 'alpha' | null = null

  constructor(config: ColorPickerConfig) {
    this.container = resolveContainer(config.container)
    this.config = config
    this.id = uniqueId('cmm-color-picker')

    const initial = config.value ?? '#000000'
    this.rgba = hexToRgba(initial)
    const hsv = rgbaToHsv(this.rgba)
    this.hue = hsv.h
    this.saturation = hsv.s
    this.brightness = hsv.v

    this.boundOnDocClick = this.onDocumentClick.bind(this)

    this.render()
  }

  /** Get the current color value in the configured format */
  getValue(): string {
    return this.formatOutput()
  }

  /** Set the color value (accepts hex, updates all internal state) */
  setValue(color: string): void {
    this.rgba = hexToRgba(color)
    const hsv = rgbaToHsv(this.rgba)
    this.hue = hsv.h
    this.saturation = hsv.s
    this.brightness = hsv.v
    this.updateAllUI()
  }

  /** Clean up DOM and event listeners */
  destroy(): void {
    document.removeEventListener('click', this.boundOnDocClick)
    document.removeEventListener('mousemove', this.onMouseMove)
    document.removeEventListener('mouseup', this.onMouseUp)
    document.removeEventListener('touchmove', this.onTouchMove)
    document.removeEventListener('touchend', this.onTouchEnd)
    this.container.innerHTML = ''
    this.panelEl = null
    this.satCanvas = null
    this.hueCanvas = null
    this.alphaCanvas = null
  }

  /** Format the output color value based on the configured format */
  private formatOutput(): string {
    const format = this.config.format ?? 'hex'
    switch (format) {
      case 'rgb':
        return formatRgbString(this.rgba)
      case 'hsl':
        return formatHslString(rgbaToHsla(this.rgba))
      case 'hex':
      default:
        return rgbaToHex(this.rgba, this.config.showAlpha === true)
    }
  }

  /** Build the full DOM structure */
  private render(): void {
    this.container.innerHTML = ''

    const root = document.createElement('div')
    root.className = 'cmm-color-picker'
    if (this.config.className) {
      root.classList.add(this.config.className)
    }
    root.id = this.id

    // Preview swatch button
    const swatch = document.createElement('button')
    swatch.className = 'cmm-color-picker__swatch'
    swatch.type = 'button'
    swatch.setAttribute('aria-label', 'Open color picker')
    swatch.setAttribute('aria-expanded', 'false')
    swatch.style.backgroundColor = rgbaToHex(this.rgba, true)
    swatch.addEventListener('click', e => {
      e.stopPropagation()
      this.togglePanel()
    })
    this.swatchEl = swatch
    root.appendChild(swatch)

    // Popover panel
    const panel = document.createElement('div')
    panel.className = 'cmm-color-picker__panel'
    panel.style.display = 'none'
    panel.addEventListener('click', e => {
      e.stopPropagation()
    })
    this.panelEl = panel

    // Saturation/brightness canvas
    const satWrap = document.createElement('div')
    satWrap.className = 'cmm-color-picker__saturation-wrap'
    const satCanvas = document.createElement('canvas')
    satCanvas.className = 'cmm-color-picker__saturation'
    satCanvas.width = 256
    satCanvas.height = 160
    satCanvas.tabIndex = 0
    satCanvas.setAttribute('role', 'slider')
    satCanvas.setAttribute('aria-label', 'Saturation and brightness')
    satCanvas.setAttribute('aria-valuemin', '0')
    satCanvas.setAttribute('aria-valuemax', '100')
    satCanvas.addEventListener('mousedown', this.onSatMouseDown)
    satCanvas.addEventListener('touchstart', this.onSatTouchStart, { passive: false })
    satCanvas.addEventListener('keydown', this.onSatKeyDown)
    this.satCanvas = satCanvas
    satWrap.appendChild(satCanvas)
    panel.appendChild(satWrap)

    // Sliders row
    const slidersRow = document.createElement('div')
    slidersRow.className = 'cmm-color-picker__sliders'

    // Hue slider
    const hueWrap = document.createElement('div')
    hueWrap.className = 'cmm-color-picker__hue-wrap'
    const hueCanvas = document.createElement('canvas')
    hueCanvas.className = 'cmm-color-picker__hue'
    hueCanvas.width = 256
    hueCanvas.height = 14
    hueCanvas.tabIndex = 0
    hueCanvas.setAttribute('role', 'slider')
    hueCanvas.setAttribute('aria-label', 'Hue')
    hueCanvas.setAttribute('aria-valuemin', '0')
    hueCanvas.setAttribute('aria-valuemax', '360')
    hueCanvas.addEventListener('mousedown', this.onHueMouseDown)
    hueCanvas.addEventListener('touchstart', this.onHueTouchStart, { passive: false })
    hueCanvas.addEventListener('keydown', this.onHueKeyDown)
    this.hueCanvas = hueCanvas
    hueWrap.appendChild(hueCanvas)
    slidersRow.appendChild(hueWrap)

    // Alpha slider (optional)
    if (this.config.showAlpha === true) {
      const alphaWrap = document.createElement('div')
      alphaWrap.className = 'cmm-color-picker__alpha-wrap'
      const alphaCanvas = document.createElement('canvas')
      alphaCanvas.className = 'cmm-color-picker__alpha'
      alphaCanvas.width = 256
      alphaCanvas.height = 14
      alphaCanvas.tabIndex = 0
      alphaCanvas.setAttribute('role', 'slider')
      alphaCanvas.setAttribute('aria-label', 'Opacity')
      alphaCanvas.setAttribute('aria-valuemin', '0')
      alphaCanvas.setAttribute('aria-valuemax', '100')
      alphaCanvas.addEventListener('mousedown', this.onAlphaMouseDown)
      alphaCanvas.addEventListener('touchstart', this.onAlphaTouchStart, { passive: false })
      alphaCanvas.addEventListener('keydown', this.onAlphaKeyDown)
      this.alphaCanvas = alphaCanvas
      alphaWrap.appendChild(alphaCanvas)
      slidersRow.appendChild(alphaWrap)
    }

    panel.appendChild(slidersRow)

    // Input fields row
    const inputRow = document.createElement('div')
    inputRow.className = 'cmm-color-picker__inputs'

    // Hex input
    const hexGroup = document.createElement('div')
    hexGroup.className = 'cmm-color-picker__input-group'
    const hexLabel = document.createElement('label')
    hexLabel.className = 'cmm-color-picker__input-label'
    hexLabel.textContent = 'Hex'
    const hexInput = document.createElement('input')
    hexInput.className = 'cmm-color-picker__input cmm-color-picker__input--hex'
    hexInput.type = 'text'
    hexInput.maxLength = 9
    hexInput.value = rgbaToHex(this.rgba, this.config.showAlpha === true)
    hexInput.addEventListener('change', () => {
      const val = hexInput.value.trim()
      const normalized = val.startsWith('#') ? val : `#${val}`
      if (/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(normalized)) {
        this.rgba = hexToRgba(normalized)
        const hsv = rgbaToHsv(this.rgba)
        this.hue = hsv.h
        this.saturation = hsv.s
        this.brightness = hsv.v
        this.updateAllUI()
        this.emitChange()
      }
    })
    this.hexInput = hexInput
    hexGroup.appendChild(hexLabel)
    hexGroup.appendChild(hexInput)
    inputRow.appendChild(hexGroup)

    // R input
    const rGroup = this.buildNumberInput('R', 0, 255, this.rgba.r, v => {
      this.rgba.r = v
      this.syncFromRgba()
    })
    this.rInput = rGroup.querySelector('input')
    inputRow.appendChild(rGroup)

    // G input
    const gGroup = this.buildNumberInput('G', 0, 255, this.rgba.g, v => {
      this.rgba.g = v
      this.syncFromRgba()
    })
    this.gInput = gGroup.querySelector('input')
    inputRow.appendChild(gGroup)

    // B input
    const bGroup = this.buildNumberInput('B', 0, 255, this.rgba.b, v => {
      this.rgba.b = v
      this.syncFromRgba()
    })
    this.bInput = bGroup.querySelector('input')
    inputRow.appendChild(bGroup)

    panel.appendChild(inputRow)

    // Copy button
    const copyRow = document.createElement('div')
    copyRow.className = 'cmm-color-picker__actions'
    const copyBtn = document.createElement('button')
    copyBtn.className = 'cmm-color-picker__copy'
    copyBtn.type = 'button'
    copyBtn.textContent = 'Copy'
    copyBtn.setAttribute('aria-label', 'Copy color value')
    copyBtn.addEventListener('click', () => {
      const value = this.formatOutput()
      void navigator.clipboard.writeText(value).then(() => {
        copyBtn.textContent = 'Copied!'
        setTimeout(() => {
          copyBtn.textContent = 'Copy'
        }, 1500)
      })
    })
    copyRow.appendChild(copyBtn)
    panel.appendChild(copyRow)

    // Preset swatches
    const presets = this.config.presets
    if (presets !== undefined && presets.length > 0) {
      const presetGrid = document.createElement('div')
      presetGrid.className = 'cmm-color-picker__presets'
      for (const preset of presets) {
        const presetBtn = document.createElement('button')
        presetBtn.className = 'cmm-color-picker__preset'
        presetBtn.type = 'button'
        presetBtn.style.backgroundColor = preset
        presetBtn.setAttribute('aria-label', `Select color ${preset}`)
        presetBtn.addEventListener('click', () => {
          this.setValue(preset)
          this.emitChange()
        })
        presetGrid.appendChild(presetBtn)
      }
      panel.appendChild(presetGrid)
    }

    root.appendChild(panel)
    this.container.appendChild(root)

    // Draw initial canvases
    this.drawSaturation()
    this.drawHue()
    if (this.config.showAlpha === true) {
      this.drawAlpha()
    }

    document.addEventListener('click', this.boundOnDocClick)
  }

  /** Build a small number input group */
  private buildNumberInput(
    label: string,
    min: number,
    max: number,
    value: number,
    onInput: (v: number) => void
  ): HTMLElement {
    const group = document.createElement('div')
    group.className = 'cmm-color-picker__input-group'
    const labelEl = document.createElement('label')
    labelEl.className = 'cmm-color-picker__input-label'
    labelEl.textContent = label
    const input = document.createElement('input')
    input.className = 'cmm-color-picker__input cmm-color-picker__input--num'
    input.type = 'number'
    input.min = String(min)
    input.max = String(max)
    input.value = String(Math.round(value))
    input.addEventListener('change', () => {
      const parsed = parseInt(input.value, 10)
      const v = clamp(isNaN(parsed) ? 0 : parsed, min, max)
      input.value = String(v)
      onInput(v)
    })
    group.appendChild(labelEl)
    group.appendChild(input)
    return group
  }

  /** Sync HSV values from the current RGBA and update UI */
  private syncFromRgba(): void {
    const hsv = rgbaToHsv(this.rgba)
    this.hue = hsv.h
    this.saturation = hsv.s
    this.brightness = hsv.v
    this.updateAllUI()
    this.emitChange()
  }

  /** Toggle the picker panel open/closed */
  private togglePanel(): void {
    if (this.isOpen) {
      this.closePanel()
    } else {
      this.openPanel()
    }
  }

  /** Open the picker panel */
  private openPanel(): void {
    if (this.panelEl === null) return
    this.panelEl.style.display = 'block'
    this.swatchEl?.setAttribute('aria-expanded', 'true')
    this.isOpen = true
    this.drawSaturation()
    this.drawHue()
    if (this.config.showAlpha === true) {
      this.drawAlpha()
    }
  }

  /** Close the picker panel */
  private closePanel(): void {
    if (this.panelEl === null) return
    this.panelEl.style.display = 'none'
    this.swatchEl?.setAttribute('aria-expanded', 'false')
    this.isOpen = false
  }

  /** Handle document click to close panel */
  private onDocumentClick(): void {
    if (this.isOpen) {
      this.closePanel()
    }
  }

  /** Draw the saturation/brightness 2D gradient */
  private drawSaturation(): void {
    const canvas = this.satCanvas
    if (canvas === null) return
    const ctx = canvas.getContext('2d')
    if (ctx === null) return
    const w = canvas.width
    const h = canvas.height

    // Base hue color
    const baseRgba = hsvToRgba(this.hue, 1, 1, 1)
    const baseHex = rgbaToHex(baseRgba, false)

    // Fill with hue
    ctx.fillStyle = baseHex
    ctx.fillRect(0, 0, w, h)

    // White gradient (left to right)
    const whiteGrad = ctx.createLinearGradient(0, 0, w, 0)
    whiteGrad.addColorStop(0, 'rgba(255, 255, 255, 1)')
    whiteGrad.addColorStop(1, 'rgba(255, 255, 255, 0)')
    ctx.fillStyle = whiteGrad
    ctx.fillRect(0, 0, w, h)

    // Black gradient (top to bottom)
    const blackGrad = ctx.createLinearGradient(0, 0, 0, h)
    blackGrad.addColorStop(0, 'rgba(0, 0, 0, 0)')
    blackGrad.addColorStop(1, 'rgba(0, 0, 0, 1)')
    ctx.fillStyle = blackGrad
    ctx.fillRect(0, 0, w, h)

    // Draw cursor
    const cx = this.saturation * w
    const cy = (1 - this.brightness) * h
    ctx.beginPath()
    ctx.arc(cx, cy, 6, 0, Math.PI * 2)
    ctx.strokeStyle = '#ffffff'
    ctx.lineWidth = 2
    ctx.stroke()
    ctx.beginPath()
    ctx.arc(cx, cy, 7, 0, Math.PI * 2)
    ctx.strokeStyle = '#000000'
    ctx.lineWidth = 1
    ctx.stroke()
  }

  /** Draw the hue rainbow slider */
  private drawHue(): void {
    const canvas = this.hueCanvas
    if (canvas === null) return
    const ctx = canvas.getContext('2d')
    if (ctx === null) return
    const w = canvas.width
    const h = canvas.height

    const grad = ctx.createLinearGradient(0, 0, w, 0)
    const stops = 7
    for (let i = 0; i <= stops; i++) {
      const ratio = i / stops
      const hueColor = hsvToRgba(ratio * 360, 1, 1, 1)
      grad.addColorStop(ratio, rgbaToHex(hueColor, false))
    }
    ctx.fillStyle = grad
    ctx.fillRect(0, 0, w, h)

    // Draw indicator
    const ix = (this.hue / 360) * w
    ctx.beginPath()
    ctx.rect(ix - 3, 0, 6, h)
    ctx.strokeStyle = '#ffffff'
    ctx.lineWidth = 2
    ctx.stroke()
    ctx.strokeStyle = '#000000'
    ctx.lineWidth = 1
    ctx.stroke()
  }

  /** Draw the alpha slider with checkerboard background */
  private drawAlpha(): void {
    const canvas = this.alphaCanvas
    if (canvas === null) return
    const ctx = canvas.getContext('2d')
    if (ctx === null) return
    const w = canvas.width
    const h = canvas.height

    // Checkerboard
    const checkSize = 4
    for (let y = 0; y < h; y += checkSize) {
      for (let x = 0; x < w; x += checkSize) {
        const isLight = (x / checkSize + y / checkSize) % 2 === 0
        ctx.fillStyle = isLight ? '#cccccc' : '#ffffff'
        ctx.fillRect(x, y, checkSize, checkSize)
      }
    }

    // Color gradient from transparent to opaque
    const opaqueHex = rgbaToHex({ ...this.rgba, a: 1 }, false)
    const grad = ctx.createLinearGradient(0, 0, w, 0)
    grad.addColorStop(0, 'rgba(0, 0, 0, 0)')
    grad.addColorStop(1, opaqueHex)
    ctx.fillStyle = grad
    ctx.fillRect(0, 0, w, h)

    // Draw indicator
    const ix = this.rgba.a * w
    ctx.beginPath()
    ctx.rect(ix - 3, 0, 6, h)
    ctx.strokeStyle = '#ffffff'
    ctx.lineWidth = 2
    ctx.stroke()
    ctx.strokeStyle = '#000000'
    ctx.lineWidth = 1
    ctx.stroke()
  }

  /** Update all UI elements to reflect current state */
  private updateAllUI(): void {
    // Swatch
    if (this.swatchEl !== null) {
      this.swatchEl.style.backgroundColor = rgbaToHex(this.rgba, true)
    }

    // Canvas
    this.drawSaturation()
    this.drawHue()
    if (this.config.showAlpha === true) {
      this.drawAlpha()
    }

    // Hex input
    if (this.hexInput !== null) {
      this.hexInput.value = rgbaToHex(this.rgba, this.config.showAlpha === true)
    }

    // RGB inputs
    if (this.rInput !== null) this.rInput.value = String(Math.round(this.rgba.r))
    if (this.gInput !== null) this.gInput.value = String(Math.round(this.rgba.g))
    if (this.bInput !== null) this.bInput.value = String(Math.round(this.rgba.b))
  }

  /** Emit the onChange callback */
  private emitChange(): void {
    this.config.onChange?.(this.formatOutput())
  }

  /** Announce color change to screen readers */
  private announceColor(): void {
    const colorName = getClosestColorName(
      Math.round(this.rgba.r),
      Math.round(this.rgba.g),
      Math.round(this.rgba.b)
    )
    const hex = rgbaToHex(this.rgba, false)
    announce(`${colorName}, ${hex}`, 'polite')
  }

  // ---- Saturation drag handlers ----

  private readonly onSatMouseDown = (e: MouseEvent): void => {
    e.preventDefault()
    this.activeDrag = 'saturation'
    this.updateSatFromPos(e.offsetX, e.offsetY)
    document.addEventListener('mousemove', this.onMouseMove)
    document.addEventListener('mouseup', this.onMouseUp)
  }

  private readonly onSatTouchStart = (e: TouchEvent): void => {
    e.preventDefault()
    this.activeDrag = 'saturation'
    const touch = e.touches[0]
    if (touch !== undefined) {
      this.updateSatFromClient(touch.clientX, touch.clientY)
    }
    document.addEventListener('touchmove', this.onTouchMove, { passive: false })
    document.addEventListener('touchend', this.onTouchEnd)
  }

  private updateSatFromPos(x: number, y: number): void {
    const canvas = this.satCanvas
    if (canvas === null) return
    this.saturation = clamp(x / canvas.width, 0, 1)
    this.brightness = clamp(1 - y / canvas.height, 0, 1)
    this.rgba = hsvToRgba(this.hue, this.saturation, this.brightness, this.rgba.a)
    this.updateAllUI()
    this.emitChange()
  }

  private updateSatFromClient(clientX: number, clientY: number): void {
    const canvas = this.satCanvas
    if (canvas === null) return
    const rect = canvas.getBoundingClientRect()
    const x = clientX - rect.left
    const y = clientY - rect.top
    this.updateSatFromPos(x, y)
  }

  // ---- Hue drag handlers ----

  private readonly onHueMouseDown = (e: MouseEvent): void => {
    e.preventDefault()
    this.activeDrag = 'hue'
    this.updateHueFromPos(e.offsetX)
    document.addEventListener('mousemove', this.onMouseMove)
    document.addEventListener('mouseup', this.onMouseUp)
  }

  private readonly onHueTouchStart = (e: TouchEvent): void => {
    e.preventDefault()
    this.activeDrag = 'hue'
    const touch = e.touches[0]
    if (touch !== undefined) {
      this.updateHueFromClient(touch.clientX)
    }
    document.addEventListener('touchmove', this.onTouchMove, { passive: false })
    document.addEventListener('touchend', this.onTouchEnd)
  }

  private updateHueFromPos(x: number): void {
    const canvas = this.hueCanvas
    if (canvas === null) return
    this.hue = clamp(x / canvas.width, 0, 1) * 360
    this.rgba = hsvToRgba(this.hue, this.saturation, this.brightness, this.rgba.a)
    this.updateAllUI()
    this.emitChange()
  }

  private updateHueFromClient(clientX: number): void {
    const canvas = this.hueCanvas
    if (canvas === null) return
    const rect = canvas.getBoundingClientRect()
    this.updateHueFromPos(clientX - rect.left)
  }

  // ---- Alpha drag handlers ----

  private readonly onAlphaMouseDown = (e: MouseEvent): void => {
    e.preventDefault()
    this.activeDrag = 'alpha'
    this.updateAlphaFromPos(e.offsetX)
    document.addEventListener('mousemove', this.onMouseMove)
    document.addEventListener('mouseup', this.onMouseUp)
  }

  private readonly onAlphaTouchStart = (e: TouchEvent): void => {
    e.preventDefault()
    this.activeDrag = 'alpha'
    const touch = e.touches[0]
    if (touch !== undefined) {
      this.updateAlphaFromClient(touch.clientX)
    }
    document.addEventListener('touchmove', this.onTouchMove, { passive: false })
    document.addEventListener('touchend', this.onTouchEnd)
  }

  private updateAlphaFromPos(x: number): void {
    const canvas = this.alphaCanvas
    if (canvas === null) return
    this.rgba.a = clamp(x / canvas.width, 0, 1)
    this.updateAllUI()
    this.emitChange()
  }

  private updateAlphaFromClient(clientX: number): void {
    const canvas = this.alphaCanvas
    if (canvas === null) return
    const rect = canvas.getBoundingClientRect()
    this.updateAlphaFromPos(clientX - rect.left)
  }

  // ---- Shared move/end handlers ----

  private readonly onMouseMove = (e: MouseEvent): void => {
    e.preventDefault()
    switch (this.activeDrag) {
      case 'saturation':
        this.updateSatFromClient(e.clientX, e.clientY)
        break
      case 'hue':
        this.updateHueFromClient(e.clientX)
        break
      case 'alpha':
        this.updateAlphaFromClient(e.clientX)
        break
    }
  }

  private readonly onMouseUp = (): void => {
    this.activeDrag = null
    document.removeEventListener('mousemove', this.onMouseMove)
    document.removeEventListener('mouseup', this.onMouseUp)
  }

  private readonly onTouchMove = (e: TouchEvent): void => {
    e.preventDefault()
    const touch = e.touches[0]
    if (touch === undefined) return
    switch (this.activeDrag) {
      case 'saturation':
        this.updateSatFromClient(touch.clientX, touch.clientY)
        break
      case 'hue':
        this.updateHueFromClient(touch.clientX)
        break
      case 'alpha':
        this.updateAlphaFromClient(touch.clientX)
        break
    }
  }

  private readonly onTouchEnd = (): void => {
    this.activeDrag = null
    document.removeEventListener('touchmove', this.onTouchMove)
    document.removeEventListener('touchend', this.onTouchEnd)
  }

  // ---- Keyboard handlers for accessibility ----

  private readonly onSatKeyDown = (e: KeyboardEvent): void => {
    const step = e.shiftKey ? 0.1 : 0.02
    let handled = true

    switch (e.key) {
      case 'ArrowRight':
        this.saturation = clamp(this.saturation + step, 0, 1)
        break
      case 'ArrowLeft':
        this.saturation = clamp(this.saturation - step, 0, 1)
        break
      case 'ArrowUp':
        this.brightness = clamp(this.brightness + step, 0, 1)
        break
      case 'ArrowDown':
        this.brightness = clamp(this.brightness - step, 0, 1)
        break
      default:
        handled = false
    }

    if (handled) {
      e.preventDefault()
      this.rgba = hsvToRgba(this.hue, this.saturation, this.brightness, this.rgba.a)
      this.updateAllUI()
      this.emitChange()
      this.announceColor()
    }
  }

  private readonly onHueKeyDown = (e: KeyboardEvent): void => {
    const step = e.shiftKey ? 30 : 5
    let handled = true

    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowUp':
        this.hue = (this.hue + step) % 360
        break
      case 'ArrowLeft':
      case 'ArrowDown':
        this.hue = (this.hue - step + 360) % 360
        break
      case 'Home':
        this.hue = 0
        break
      case 'End':
        this.hue = 359
        break
      default:
        handled = false
    }

    if (handled) {
      e.preventDefault()
      this.rgba = hsvToRgba(this.hue, this.saturation, this.brightness, this.rgba.a)
      this.updateAllUI()
      this.emitChange()
      this.announceColor()
    }
  }

  private readonly onAlphaKeyDown = (e: KeyboardEvent): void => {
    const step = e.shiftKey ? 0.1 : 0.05
    let handled = true

    switch (e.key) {
      case 'ArrowRight':
      case 'ArrowUp':
        this.rgba.a = clamp(this.rgba.a + step, 0, 1)
        break
      case 'ArrowLeft':
      case 'ArrowDown':
        this.rgba.a = clamp(this.rgba.a - step, 0, 1)
        break
      case 'Home':
        this.rgba.a = 0
        break
      case 'End':
        this.rgba.a = 1
        break
      default:
        handled = false
    }

    if (handled) {
      e.preventDefault()
      this.updateAllUI()
      this.emitChange()
      announce(`Opacity ${String(Math.round(this.rgba.a * 100))} percent`, 'polite')
    }
  }
}
