<template>
  <div
    ref="rootRef"
    class="vm-color-picker"
    :class="{ 'is-disabled': disabled, 'is-sm': size === 'sm', 'is-open': open }"
  >
    <button
      ref="triggerRef"
      type="button"
      class="vm-color-picker__swatch"
      :disabled="disabled"
      :title="disabled ? undefined : crudLabel('pickColor')"
      @click="toggle"
    >
      <span
        class="vm-color-picker__swatch-fill"
        :style="{ background: cssColor }"
      />
    </button>

      <input
      type="text"
      class="vm-color-picker__input"
      :value="inlineDisplay"
      :placeholder="placeholderText"
        :disabled="disabled"
      spellcheck="false"
      autocomplete="off"
      :title="isThemeBound ? themeBoundLabel : undefined"
      @input="onInlineInput"
      @focus="openPanel"
      @blur="onInlineBlur"
      @keydown.esc.stop="onEscEnd"
    />

    <Teleport to="body">
      <FocusScope
        v-if="open"
        as-child
        loop
        :trapped="true"
        @unmount-auto-focus="onPanelUnmountAutoFocus"
      >
        <div
          ref="panelRef"
          class="vm-color-picker__panel vm-dialog-float"
          :style="panelStyle"
          @pointerdown.stop
          @mousedown.stop
        >
          <div class="vm-color-picker__toolbar">
            <div
              v-if="hasThemeOptions"
              class="vm-color-picker__modes"
              role="tablist"
            >
              <button
                type="button"
                class="vm-color-picker__mode"
                :class="{ 'is-on': panelMode === 'theme' }"
                role="tab"
                @click="panelMode = 'theme'"
              >
                {{ crudLabel('theme') }}
              </button>
              <button
                type="button"
                class="vm-color-picker__mode"
                :class="{ 'is-on': panelMode === 'picker' }"
                role="tab"
                @click="panelMode = 'picker'"
              >
                {{ crudLabel('colorPickerTab') }}
              </button>
            </div>
            <span v-else class="vm-color-picker__toolbar-spacer" />
            <button
              type="button"
              class="vm-color-picker__close"
              :title="crudLabel('dismiss')"
              :aria-label="crudLabel('dismiss')"
              @click="closePanel"
            >
              <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
                <path
                  fill="currentColor"
                  d="M6.4 19 5 17.6 10.6 12 5 6.4 6.4 5 12 10.6 17.6 5 19 6.4 13.4 12 19 17.6 17.6 19 12 13.4 6.4 19Z"
                />
              </svg>
            </button>
          </div>

          <div v-if="panelMode === 'theme' && hasThemeOptions" class="vm-color-picker__theme">
            <button
              v-for="opt in themeOptions"
              :key="opt.key"
              type="button"
              class="vm-color-picker__theme-item"
              :class="{ 'is-on': themeBoundKey === opt.key }"
              @click="pickTheme(opt.key)"
            >
              <span
                class="vm-color-picker__theme-swatch"
                :style="{ background: opt.value }"
              />
              <span class="vm-color-picker__theme-meta">
                <span class="vm-color-picker__theme-label">{{ opt.label }}</span>
                <span class="vm-color-picker__theme-key">{{ opt.key }}</span>
              </span>
            </button>
          </div>

          <template v-else>
          <div
            ref="svRef"
            class="vm-color-picker__sv"
            :style="{ background: svBg }"
            @pointerdown="onSvPointer"
          >
            <span
              class="vm-color-picker__sv-cursor"
              :style="svCursorStyle"
            />
          </div>

          <div class="vm-color-picker__sliders">
            <button
              v-if="hasEyeDropper"
              type="button"
              class="vm-color-picker__eyedropper"
              :title="crudLabel('eyedropper')"
              @click="pickEyeDropper"
            >
              <!-- 内联 SVG：不依赖 remixicon 字体是否已加载 -->
              <svg
                class="vm-color-picker__eyedropper-ico"
                viewBox="0 0 24 24"
                width="16"
                height="16"
                aria-hidden="true"
              >
                <path
                  fill="currentColor"
                  d="M19.07 3.66a2.5 2.5 0 0 1 0 3.54l-1.42 1.41 2.12 2.12-1.41 1.42-2.12-2.13-7.07 7.07c-.3.3-.68.48-1.08.54l-3.36.48a.75.75 0 0 1-.84-.84l.48-3.36c.06-.4.24-.78.54-1.08l7.07-7.07-1.41-1.41 1.41-1.42 2.12 2.13 1.42-1.42a2.5 2.5 0 0 1 3.54 0Zm-2.12 2.12.71-.7a1 1 0 0 0-1.42-1.42l-.7.71 1.41 1.41ZM8.48 15.9l-.17.17-.28 1.9 1.9-.28.17-.17 5.66-5.66-1.62-1.62-5.66 5.66Z"
                />
              </svg>
            </button>
            <div class="vm-color-picker__slider-col">
              <div
                ref="hueRef"
                class="vm-color-picker__hue"
                @pointerdown="onHuePointer"
              >
                <span
                  class="vm-color-picker__slider-thumb"
                  :style="{ left: thumbLeft(hue / 360) }"
                />
              </div>
              <div
                ref="alphaRef"
                class="vm-color-picker__alpha"
                @pointerdown="onAlphaPointer"
              >
                <span
                  class="vm-color-picker__alpha-fill"
                  :style="{ background: alphaBg }"
                />
                <span
                  class="vm-color-picker__slider-thumb"
                  :style="{ left: thumbLeft(alpha) }"
                />
              </div>
            </div>
          </div>

          <div class="vm-color-picker__footer">
            <div class="vm-color-picker__format-wrap">
              <button
                type="button"
                class="vm-color-picker__format"
                :class="{ 'is-open': formatOpen }"
                :title="crudLabel('colorFormat')"
                @click.stop="toggleFormatMenu"
              >
                {{ formatLabel }}
                <svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
                  <path fill="currentColor" d="M12 15.4 6.6 10l1.4-1.4 4 4 4-4L17.4 10 12 15.4Z" />
                </svg>
              </button>
              <div v-if="formatOpen" class="vm-color-picker__format-menu" role="listbox">
                <button
                  v-for="opt in FORMAT_OPTS"
                  :key="opt.id"
                  type="button"
                  class="vm-color-picker__format-item"
                  :class="{ 'is-active': colorFormat === opt.id }"
                  role="option"
                  :aria-selected="colorFormat === opt.id"
                  @click.stop="setFormat(opt.id)"
                >
                  <span class="vm-color-picker__format-check" aria-hidden="true">
                    <svg v-if="colorFormat === opt.id" viewBox="0 0 24 24" width="14" height="14">
                      <path
                        fill="currentColor"
                        d="M9.55 17.6 4.9 12.95l1.4-1.4 3.25 3.25 7.15-7.15 1.4 1.4-8.55 8.55Z"
                      />
                    </svg>
                  </span>
                  {{ opt.label }}
                </button>
              </div>
            </div>
    <input
      type="text"
              class="vm-color-picker__value"
              :value="panelValue"
      spellcheck="false"
      autocomplete="off"
              @input="onPanelValueInput"
              @blur="onPanelValueBlur"
              @keydown.esc.stop="onEscEnd"
            />
            <input
              type="text"
              class="vm-color-picker__alpha-pct"
              :value="alphaPctText"
              spellcheck="false"
              autocomplete="off"
              @input="onAlphaPctInput"
              @blur="onAlphaPctBlur"
              @keydown.esc.stop="onEscEnd"
            />
          </div>
          </template>
        </div>
      </FocusScope>
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { FocusScope } from 'reka-ui'
import { POPUP_TRIGGER_GAP } from '../../lib/dialog-float'
import {
  ADMIN_THEME_COLOR_DEFS,
  resolveCssThemeColorOptions,
  type VmColorThemeOption,
} from '../../lib/theme-color-options'
import { crudLabel, getCrudConfig } from '../style'

defineOptions({ name: 'vm-color-picker' })

export type { VmColorThemeOption } from '../../lib/theme-color-options'

const props = withDefaults(
  defineProps<{
    modelValue?: string | null
    disabled?: boolean
    placeholder?: string
    size?: 'default' | 'sm'
    themeOptions?: VmColorThemeOption[]
  }>(),
  {
    placeholder: '#000000',
    size: 'default',
    themeOptions: () => [],
  },
)

const emit = defineEmits<{
  'update:modelValue': [v: string]
  change: [v: string]
}>()

type Rgba = { r: number; g: number; b: number; a: number }
type Hsv = { h: number; s: number; v: number }
type ColorFormat = 'hex' | 'rgb' | 'css' | 'hsl' | 'hsb'

function parseCssVarRef(raw: string): string | null {
  const s = String(raw || '').trim()
  const m = s.match(/^var\(\s*--([a-zA-Z0-9_-]+)\s*(?:,[^)]*)?\)$/i)
  return m ? m[1]! : null
}

function toCssVarRef(key: string): string {
  const k = String(key || '')
    .trim()
    .replace(/^--/, '')
  return k ? `var(--${k})` : ''
}

const FORMAT_OPTS: { id: ColorFormat; label: string }[] = [
  { id: 'hex', label: 'Hex' },
  { id: 'rgb', label: 'RGB' },
  { id: 'css', label: 'CSS' },
  { id: 'hsl', label: 'HSL' },
  { id: 'hsb', label: 'HSB' },
]

const THUMB_PX = 14

const rootRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const svRef = ref<HTMLElement | null>(null)
const hueRef = ref<HTMLElement | null>(null)
const alphaRef = ref<HTMLElement | null>(null)

const open = ref(false)
const suppressOpen = ref(false)
const panelStyle = ref<Record<string, string>>({})
const formatOpen = ref(false)
const colorFormat = ref<ColorFormat>('hex')
/** 面板：主题变量 | 取色器 */
const panelMode = ref<'theme' | 'picker'>('picker')

const hue = ref(0)
const sat = ref(0)
const val = ref(0)
const alpha = ref(1)

/** 行内 Hex（不含 #）；面板按当前格式展示 */
const inlineHex = ref('')
const panelValue = ref('')
const alphaPctText = ref('100%')

const hasEyeDropper = ref(false)

const themeOptions = computed(() => {
  if (props.themeOptions?.length) return props.themeOptions
  // open 变化时重读 CSS 变量（亮暗切换后预览色同步）
  void open.value
  const fromConfig = getCrudConfig().listThemeColorOptions?.()
  if (fromConfig?.length) return fromConfig
  return resolveCssThemeColorOptions(ADMIN_THEME_COLOR_DEFS)
})
const hasThemeOptions = computed(() => themeOptions.value.length > 0)
const themeBoundKey = computed(() => parseCssVarRef(props.modelValue ?? ''))
const isThemeBound = computed(() => Boolean(themeBoundKey.value))
const themeBoundOpt = computed(() => {
  const k = themeBoundKey.value
  if (!k) return null
  return themeOptions.value.find((o) => o.key === k) || null
})
const themeBoundLabel = computed(() => {
  const opt = themeBoundOpt.value
  if (opt) return opt.label
  const k = themeBoundKey.value
  return k ? k : ''
})
const inlineDisplay = computed(() => {
  if (isThemeBound.value) {
    return String(props.modelValue || '').trim() || themeBoundLabel.value
  }
  return inlineHex.value
})

/** 行内：主题变量 / mustache，不当作 hex 强行加 # */
function isInlineExpr(raw: string): boolean {
  const s = String(raw || '').trim()
  if (!s) return false
  if (/\{\{[\s\S]*?\}\}/.test(s)) return true
  if (parseCssVarRef(s)) return true
  if (/^var\s*\(/i.test(s)) return true
  return false
}

const placeholderText = computed(() => {
  const p = props.placeholder || '#000000'
  return p.startsWith('#') ? p.slice(1) : p
})

const formatLabel = computed(
  () => FORMAT_OPTS.find((o) => o.id === colorFormat.value)?.label ?? 'Hex',
)

function clamp(n: number, min: number, max: number) {
  return Math.min(max, Math.max(min, n))
}

/** 拇指中心落在轨道内，两端不溢出 */
function thumbLeft(ratio: number) {
  const t = clamp(ratio, 0, 1)
  return `calc(${t} * (100% - ${THUMB_PX}px) + ${THUMB_PX / 2}px)`
}

function toHex2(n: number) {
  return clamp(Math.round(n), 0, 255).toString(16).padStart(2, '0')
}

function rgbToHex(r: number, g: number, b: number, a = 1): string {
  const base = `#${toHex2(r)}${toHex2(g)}${toHex2(b)}`
  if (a >= 1 - 1e-6) return base
  return `${base}${toHex2(a * 255)}`
}

function hsvToRgb(h: number, s: number, v: number): { r: number; g: number; b: number } {
  const hh = ((h % 360) + 360) % 360
  const c = v * s
  const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
  const m = v - c
  let rp = 0
  let gp = 0
  let bp = 0
  if (hh < 60) {
    rp = c
    gp = x
  } else if (hh < 120) {
    rp = x
    gp = c
  } else if (hh < 180) {
    gp = c
    bp = x
  } else if (hh < 240) {
    gp = x
    bp = c
  } else if (hh < 300) {
    rp = x
    bp = c
  } else {
    rp = c
    bp = x
  }
  return {
    r: (rp + m) * 255,
    g: (gp + m) * 255,
    b: (bp + m) * 255,
  }
}

function rgbToHsv(r: number, g: number, b: number): Hsv {
  const rr = r / 255
  const gg = g / 255
  const bb = b / 255
  const max = Math.max(rr, gg, bb)
  const min = Math.min(rr, gg, bb)
  const d = max - min
  let h = 0
  if (d !== 0) {
    if (max === rr) h = ((gg - bb) / d) % 6
    else if (max === gg) h = (bb - rr) / d + 2
    else h = (rr - gg) / d + 4
    h *= 60
    if (h < 0) h += 360
  }
  const s = max === 0 ? 0 : d / max
  return { h, s, v: max }
}

function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
  const rr = r / 255
  const gg = g / 255
  const bb = b / 255
  const max = Math.max(rr, gg, bb)
  const min = Math.min(rr, gg, bb)
  const l = (max + min) / 2
  const d = max - min
  let h = 0
  let s = 0
  if (d !== 0) {
    s = d / (1 - Math.abs(2 * l - 1))
    if (max === rr) h = ((gg - bb) / d) % 6
    else if (max === gg) h = (bb - rr) / d + 2
    else h = (rr - gg) / d + 4
    h *= 60
    if (h < 0) h += 360
  }
  return { h, s, l }
}

function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
  const hh = ((h % 360) + 360) % 360
  const c = (1 - Math.abs(2 * l - 1)) * s
  const x = c * (1 - Math.abs(((hh / 60) % 2) - 1))
  const m = l - c / 2
  let rp = 0
  let gp = 0
  let bp = 0
  if (hh < 60) {
    rp = c
    gp = x
  } else if (hh < 120) {
    rp = x
    gp = c
  } else if (hh < 180) {
    gp = c
    bp = x
  } else if (hh < 240) {
    gp = x
    bp = c
  } else if (hh < 300) {
    rp = x
    bp = c
  } else {
    rp = c
    bp = x
  }
  return {
    r: (rp + m) * 255,
    g: (gp + m) * 255,
    b: (bp + m) * 255,
  }
}

/** 解析为 rgba；无效返回 null */
function parseColor(raw: string): Rgba | null {
  const s = raw.trim()
  if (!s) return null

  if (s.startsWith('#')) {
    let h = s.slice(1)
  if (/^[0-9a-fA-F]{3}$/.test(h)) {
    h = h
      .split('')
      .map((c) => c + c)
      .join('')
  }
    if (/^[0-9a-fA-F]{6}$/.test(h)) {
      return {
        r: parseInt(h.slice(0, 2), 16),
        g: parseInt(h.slice(2, 4), 16),
        b: parseInt(h.slice(4, 6), 16),
        a: 1,
      }
    }
    if (/^[0-9a-fA-F]{8}$/.test(h)) {
      return {
        r: parseInt(h.slice(0, 2), 16),
        g: parseInt(h.slice(2, 4), 16),
        b: parseInt(h.slice(4, 6), 16),
        a: parseInt(h.slice(6, 8), 16) / 255,
      }
    }
  return null
}

  if (/^[0-9a-fA-F]{3}$/.test(s) || /^[0-9a-fA-F]{6}$/.test(s) || /^[0-9a-fA-F]{8}$/.test(s)) {
    return parseColor(`#${s}`)
  }

  const m = s.match(
    /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i,
  )
  if (m) {
    return {
      r: clamp(Number(m[1]), 0, 255),
      g: clamp(Number(m[2]), 0, 255),
      b: clamp(Number(m[3]), 0, 255),
      a: m[4] != null ? clamp(Number(m[4]), 0, 1) : 1,
    }
  }

  const hsl = s.match(
    /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%(?:\s*,\s*([\d.]+))?\s*\)$/i,
  )
  if (hsl) {
    const rgb = hslToRgb(Number(hsl[1]), Number(hsl[2]) / 100, Number(hsl[3]) / 100)
    return {
      r: rgb.r,
      g: rgb.g,
      b: rgb.b,
      a: hsl[4] != null ? clamp(Number(hsl[4]), 0, 1) : 1,
    }
  }

  return null
}

/** 按当前格式解析面板中间输入（不改变 alpha，除非 CSS/rgba 自带） */
function parsePanelValue(raw: string, keepAlpha: number): Rgba | null {
  const s = raw.trim()
  if (!s) return null
  const fmt = colorFormat.value

  if (fmt === 'hex' || fmt === 'css') {
    const parsed = parseColor(s)
    if (!parsed) return null
    if (fmt === 'hex') parsed.a = keepAlpha
    return parsed
  }

  if (fmt === 'rgb') {
    const parts = s.split(/[,\s]+/).filter(Boolean)
    if (parts.length < 3) return null
    const r = Number(parts[0])
    const g = Number(parts[1])
    const b = Number(parts[2])
    if (![r, g, b].every(Number.isFinite)) return null
    return { r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255), a: keepAlpha }
  }

  if (fmt === 'hsl') {
    const parts = s.replace(/%/g, '').split(/[,\s]+/).filter(Boolean)
    if (parts.length < 3) return null
    const h = Number(parts[0])
    const satPct = Number(parts[1])
    const lPct = Number(parts[2])
    if (![h, satPct, lPct].every(Number.isFinite)) return null
    const rgb = hslToRgb(h, clamp(satPct, 0, 100) / 100, clamp(lPct, 0, 100) / 100)
    return { ...rgb, a: keepAlpha }
  }

  if (fmt === 'hsb') {
    const parts = s.replace(/%/g, '').split(/[,\s]+/).filter(Boolean)
    if (parts.length < 3) return null
    const h = Number(parts[0])
    const sPct = Number(parts[1])
    const bPct = Number(parts[2])
    if (![h, sPct, bPct].every(Number.isFinite)) return null
    const rgb = hsvToRgb(h, clamp(sPct, 0, 100) / 100, clamp(bPct, 0, 100) / 100)
    return { ...rgb, a: keepAlpha }
  }

  return null
}

function currentRgb() {
  return hsvToRgb(hue.value, sat.value, val.value)
}

function currentHex(): string {
  const { r, g, b } = currentRgb()
  return rgbToHex(r, g, b, alpha.value)
}

function formatPanelValue(): string {
  const { r, g, b } = currentRgb()
  const rr = Math.round(r)
  const gg = Math.round(g)
  const bb = Math.round(b)
  const a = alpha.value
  switch (colorFormat.value) {
    case 'rgb':
      return `${rr}, ${gg}, ${bb}`
    case 'css':
      return a < 1 - 1e-6
        ? `rgba(${rr}, ${gg}, ${bb}, ${Number(a.toFixed(2))})`
        : `#${toHex2(rr)}${toHex2(gg)}${toHex2(bb)}`
    case 'hsl': {
      const hsl = rgbToHsl(rr, gg, bb)
      return `${Math.round(hsl.h)}, ${Math.round(hsl.s * 100)}%, ${Math.round(hsl.l * 100)}%`
    }
    case 'hsb':
      return `${Math.round(hue.value)}, ${Math.round(sat.value * 100)}%, ${Math.round(val.value * 100)}%`
    case 'hex':
    default:
      return `${toHex2(rr)}${toHex2(gg)}${toHex2(bb)}`
  }
}

const cssColor = computed(() => {
  const themePreview = themeBoundOpt.value?.value
  if (themePreview) {
    const parsed = parseColor(themePreview)
    if (parsed) {
      const { r, g, b, a } = parsed
      return a < 1 ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`
    }
    return String(themePreview)
  }
  if (themeBoundKey.value) {
    return `var(--${themeBoundKey.value})`
  }
  const raw = props.modelValue ?? ''
  const parsed = parseColor(raw)
  if (!parsed && !String(raw).trim()) return 'transparent'
  if (!parsed) return 'transparent'
  const { r, g, b, a } = parsed
  return a < 1 ? `rgba(${r},${g},${b},${a})` : `rgb(${r},${g},${b})`
})

function pickTheme(key: string) {
  const ref = toCssVarRef(key)
  if (!ref) return
  commit(ref)
  closePanel()
}

const svBg = computed(() => {
  const { r, g, b } = hsvToRgb(hue.value, 1, 1)
  return `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)}))`
})

const alphaBg = computed(() => {
  const { r, g, b } = currentRgb()
  const c = `rgb(${Math.round(r)},${Math.round(g)},${Math.round(b)})`
  return `linear-gradient(to right, transparent, ${c})`
})

const svCursorStyle = computed(() => ({
  left: `${sat.value * 100}%`,
  top: `${(1 - val.value) * 100}%`,
}))

function syncTextsFromState() {
  const hex = currentHex()
  const bare = hex.startsWith('#') ? hex.slice(1) : hex
  inlineHex.value = bare.slice(0, 6)
  panelValue.value = formatPanelValue()
  alphaPctText.value = `${Math.round(alpha.value * 100)}%`
}

function applyRgba(c: Rgba, emitValue = true) {
  const hsv = rgbToHsv(c.r, c.g, c.b)
  hue.value = hsv.h
  sat.value = hsv.s
  val.value = hsv.v
  alpha.value = clamp(c.a, 0, 1)
  syncTextsFromState()
  if (emitValue) commit(currentHex())
}

/** 3/4 位短 hex：可预览，但行内禁止展开成 6/8 位 */
function hexBodyOf(raw: string) {
  return String(raw || '')
    .trim()
    .replace(/^#/, '')
}

function isShortHexBody(body: string) {
  return /^[0-9a-fA-F]{3}$/.test(body) || /^[0-9a-fA-F]{4}$/.test(body)
}

function applyHexPreview(body: string) {
  const parsed = parseColor(`#${body}`)
  if (!parsed) return
  const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
  hue.value = hsv.h
  sat.value = hsv.s
  val.value = hsv.v
  if (body.length === 4 || body.length === 8) alpha.value = parsed.a
}

function loadFromModel(raw: string | null | undefined) {
  const s = raw ?? ''
  const varKey = parseCssVarRef(s)
  if (varKey) {
    const opt = themeOptions.value.find((o) => o.key === varKey)
    const preview = opt?.value || ''
    if (preview) {
      const parsed = parseColor(preview)
      if (parsed) {
        const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
        hue.value = hsv.h
        sat.value = hsv.s
        val.value = hsv.v
        alpha.value = parsed.a
        syncTextsFromState()
        return
      }
    }
    inlineHex.value = varKey
    panelValue.value = varKey
    return
  }
  if (!s.trim()) {
    hue.value = 0
    sat.value = 0
    val.value = 0
    alpha.value = 1
    inlineHex.value = ''
    panelValue.value = ''
    alphaPctText.value = '100%'
    return
  }
  const body = hexBodyOf(s)
  // 短 hex / 未满 6 位：保留位数，勿 sync 成 6 位
  if (/^[0-9a-fA-F]+$/.test(body) && body.length !== 6 && body.length !== 8) {
    if (isShortHexBody(body)) applyHexPreview(body)
    inlineHex.value = body
    panelValue.value = body
    alphaPctText.value = `${Math.round(alpha.value * 100)}%`
    return
  }
  const parsed = parseColor(s)
  if (!parsed) {
    inlineHex.value = s.startsWith('#') ? s.slice(1) : s
    return
  }
  const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
  hue.value = hsv.h
  sat.value = hsv.s
  val.value = hsv.v
  alpha.value = parsed.a
  syncTextsFromState()
}

watch(
  () => props.modelValue,
  (v) => {
    if (open.value) {
      const cur = currentHex()
      const parsed = parseColor(v ?? '')
      if (parsed && rgbToHex(parsed.r, parsed.g, parsed.b, parsed.a) === cur) return
    }
    loadFromModel(v)
  },
  { immediate: true },
)

function commit(v: string) {
  emit('update:modelValue', v)
  emit('change', v)
}

function commitFromState() {
  syncTextsFromState()
  commit(currentHex())
}

function onInlineInput(e: Event) {
  const rawInput = (e.target as HTMLInputElement).value
  if (isInlineExpr(rawInput)) {
    inlineHex.value = rawInput
    commit(rawInput.trim())
    return
  }
  const raw = hexBodyOf(rawInput)
  inlineHex.value = raw
  if (!raw) {
    commit('')
    return
  }
  // 纯 hex：仅 6/8 位规范化；3 位等中间态原样写入，禁止 3→6 自动补齐
  if (/^[0-9a-fA-F]+$/.test(raw)) {
    if (raw.length === 6 || raw.length === 8) {
      const parsed = parseColor(`#${raw}`)
      if (parsed) {
        if (raw.length === 6) parsed.a = alpha.value
        const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
        hue.value = hsv.h
        sat.value = hsv.s
        val.value = hsv.v
        if (raw.length === 8) alpha.value = parsed.a
        panelValue.value = formatPanelValue()
        commit(currentHex())
        return
      }
    }
    if (isShortHexBody(raw)) applyHexPreview(raw)
    commit(`#${raw}`)
    return
  }
  const parsed = parseColor(rawInput)
  if (parsed) {
    parsed.a = alpha.value
    const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
    hue.value = hsv.h
    sat.value = hsv.s
    val.value = hsv.v
    panelValue.value = formatPanelValue()
    commit(currentHex())
    return
  }
  commit(raw)
}

function onInlineBlur() {
  const t = inlineHex.value.trim()
  if (!t) {
    commit('')
    hue.value = 0
    sat.value = 0
    val.value = 0
    alpha.value = 1
    inlineHex.value = ''
    panelValue.value = ''
    alphaPctText.value = '100%'
    return
  }
  if (isInlineExpr(t)) {
    commit(t)
    return
  }
  const body = hexBodyOf(t)
  if (/^[0-9a-fA-F]+$/.test(body)) {
    if (body.length === 6 || body.length === 8) {
      const parsed = parseColor(`#${body}`)
      if (parsed) {
        if (body.length === 6) parsed.a = alpha.value
        applyRgba(parsed, true)
        return
      }
    }
    if (isShortHexBody(body)) applyHexPreview(body)
    inlineHex.value = body
    commit(`#${body}`)
    return
  }
  const parsed = parseColor(t)
  if (parsed) {
    parsed.a = alpha.value
    applyRgba(parsed, true)
    return
  }
  loadFromModel(props.modelValue)
}

function onPanelValueInput(e: Event) {
  panelValue.value = (e.target as HTMLInputElement).value
  const parsed = parsePanelValue(panelValue.value, alpha.value)
  if (!parsed) return
  const hsv = rgbToHsv(parsed.r, parsed.g, parsed.b)
  hue.value = hsv.h
  sat.value = hsv.s
  val.value = hsv.v
  if (colorFormat.value === 'css') alpha.value = parsed.a
  const hex = currentHex()
  inlineHex.value = hex.replace(/^#/, '').slice(0, 6)
  alphaPctText.value = `${Math.round(alpha.value * 100)}%`
  commit(hex)
}

function onPanelValueBlur() {
  const parsed = parsePanelValue(panelValue.value, alpha.value)
  if (parsed) {
    if (colorFormat.value !== 'css') parsed.a = alpha.value
    applyRgba(parsed, true)
  } else {
    syncTextsFromState()
  }
}

function onAlphaPctInput(e: Event) {
  alphaPctText.value = (e.target as HTMLInputElement).value
  const n = Number(String(alphaPctText.value).replace(/%/g, '').trim())
  if (!Number.isFinite(n)) return
  alpha.value = clamp(n / 100, 0, 1)
  commitFromState()
}

function onAlphaPctBlur() {
  const n = Number(String(alphaPctText.value).replace(/%/g, '').trim())
  if (Number.isFinite(n)) {
    alpha.value = clamp(n / 100, 0, 1)
  }
  syncTextsFromState()
  commit(currentHex())
}

function toggleFormatMenu() {
  formatOpen.value = !formatOpen.value
}

function setFormat(id: ColorFormat) {
  colorFormat.value = id
  formatOpen.value = false
  syncTextsFromState()
}

function ratioFromEvent(el: HTMLElement, e: PointerEvent, axis: 'x' | 'y' | 'both') {
  const r = el.getBoundingClientRect()
  const x = clamp((e.clientX - r.left) / r.width, 0, 1)
  const y = clamp((e.clientY - r.top) / r.height, 0, 1)
  if (axis === 'x') return { x, y: 0 }
  if (axis === 'y') return { x: 0, y }
  return { x, y }
}

function bindDrag(
  el: HTMLElement,
  e: PointerEvent,
  onMove: (ev: PointerEvent) => void,
) {
  e.preventDefault()
  el.setPointerCapture(e.pointerId)
  onMove(e)
  const move = (ev: PointerEvent) => onMove(ev)
  const up = (ev: PointerEvent) => {
    el.releasePointerCapture(ev.pointerId)
    el.removeEventListener('pointermove', move)
    el.removeEventListener('pointerup', up)
    el.removeEventListener('pointercancel', up)
  }
  el.addEventListener('pointermove', move)
  el.addEventListener('pointerup', up)
  el.addEventListener('pointercancel', up)
}

function onSvPointer(e: PointerEvent) {
  const el = svRef.value
  if (!el) return
  bindDrag(el, e, (ev) => {
    const { x, y } = ratioFromEvent(el, ev, 'both')
    sat.value = x
    val.value = 1 - y
    commitFromState()
  })
}

function onHuePointer(e: PointerEvent) {
  const el = hueRef.value
  if (!el) return
  bindDrag(el, e, (ev) => {
    const { x } = ratioFromEvent(el, ev, 'x')
    hue.value = x * 360
    commitFromState()
  })
}

function onAlphaPointer(e: PointerEvent) {
  const el = alphaRef.value
  if (!el) return
  bindDrag(el, e, (ev) => {
    const { x } = ratioFromEvent(el, ev, 'x')
    alpha.value = x
    commitFromState()
  })
}

async function pickEyeDropper() {
  const ED = (
    window as unknown as {
      EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> }
    }
  ).EyeDropper
  if (!ED) return
  try {
    const result = await new ED().open()
    const parsed = parseColor(result.sRGBHex)
    if (parsed) {
      parsed.a = alpha.value
      applyRgba(parsed, true)
    }
  } catch {
    /* 用户取消 */
  }
}

function syncPanelPos() {
  const el = triggerRef.value
  if (!el) return
  const r = el.getBoundingClientRect()
  const width = 240
  // 贴触发框左侧 + 底部
  panelStyle.value = {
    position: 'fixed',
    top: `${r.bottom + POPUP_TRIGGER_GAP}px`,
    left: `${r.left}px`,
    width: `${width}px`,
    zIndex: '100',
  }
}

async function openPanel() {
  if (props.disabled || suppressOpen.value) return
  loadFromModel(props.modelValue)
  formatOpen.value = false
  if (hasThemeOptions.value) {
    const bound = parseCssVarRef(props.modelValue ?? '')
    const empty = !(props.modelValue ?? '').trim()
    panelMode.value = bound || empty ? 'theme' : 'picker'
  } else {
    panelMode.value = 'picker'
  }
  open.value = true
  await nextTick()
  syncPanelPos()
}

function onPanelUnmountAutoFocus(event: Event) {
  event.preventDefault()
}

function closePanel() {
  if (!open.value) return
  suppressOpen.value = true
  formatOpen.value = false
  open.value = false
  nextTick(() => {
    requestAnimationFrame(() => {
      suppressOpen.value = false
    })
  })
}

/** Esc：收起面板并失焦 */
function onEscEnd(e: KeyboardEvent) {
  closePanel()
  const el = e.target
  if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) el.blur()
}

async function toggle() {
  if (props.disabled) return
  if (open.value) {
    closePanel()
    return
  }
  await openPanel()
}

function onDocPointer(e: Event) {
  if (!open.value) return
  const t = e.target as Node | null
  if (!t) return
  if (rootRef.value?.contains(t)) return
  if (panelRef.value?.contains(t)) return
  closePanel()
}

function onWinChange() {
  if (open.value) syncPanelPos()
}

onMounted(() => {
  hasEyeDropper.value = typeof window !== 'undefined' && 'EyeDropper' in window
  document.addEventListener('pointerdown', onDocPointer, true)
  window.addEventListener('resize', onWinChange)
  window.addEventListener('scroll', onWinChange, true)
})

onUnmounted(() => {
  document.removeEventListener('pointerdown', onDocPointer, true)
  window.removeEventListener('resize', onWinChange)
  window.removeEventListener('scroll', onWinChange, true)
})
</script>

<style lang="scss" scoped>
.vm-color-picker {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-width: 0;

  &.is-disabled {
    opacity: 0.6;
    pointer-events: none;
  }
}

.vm-color-picker__swatch {
  position: relative;
  flex-shrink: 0;
  width: var(--vm-control-height);
  height: var(--vm-control-height);
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--vm-control-radius);
  background: var(--muted);
  cursor: pointer;
  overflow: hidden;

  outline: none;

  &:hover:not(:disabled) {
    border-color: var(--brand, #4e5dff);
  }

  /* 弹层触发：打开态不额外描边 */

  &:disabled {
    cursor: not-allowed;
  }
}

.vm-color-picker.is-sm .vm-color-picker__swatch {
  width: 16px;
  height: 16px;
  border-radius: 3px;
}

.vm-color-picker__swatch-fill {
  display: block;
  width: 100%;
  height: 100%;
  background-image:
    linear-gradient(45deg, #ccc 25%, transparent 25%),
    linear-gradient(-45deg, #ccc 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #ccc 75%),
    linear-gradient(-45deg, transparent 75%, #ccc 75%);
  background-size: 8px 8px;
  background-position:
    0 0,
    0 4px,
    4px -4px,
    -4px 0;
}

.vm-color-picker__input {
  flex: 1;
  min-width: 0;
  height: var(--vm-control-height);
  padding: 0 var(--vm-control-padding-x);
  border: 1px solid var(--border);
  border-radius: var(--vm-control-radius);
  background: var(--muted);
  color: var(--foreground);
  font-size: 13px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  outline: none;

  /* 弹层触发旁输入：不展示聚焦描边 */

  &::placeholder {
    color: var(--muted-foreground);
  }
}

.vm-color-picker.is-sm .vm-color-picker__input {
  height: 24px;
  padding: 0 6px;
  font-size: 12px;
  border-radius: 4px;
}
</style>

<style lang="scss">
/* Teleport 到 body，不可 scoped */
.vm-color-picker__panel {
  box-sizing: border-box;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--popover, var(--background, #fff));
  color: var(--foreground);
  box-shadow:
    0 4px 6px -1px rgb(0 0 0 / 10%),
    0 10px 24px -4px rgb(0 0 0 / 12%);
  user-select: none;
  overflow: visible;
}

.vm-color-picker__toolbar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
}

.vm-color-picker__toolbar-spacer {
  flex: 1;
  min-width: 0;
}

.vm-color-picker__close {
  display: inline-flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--muted-foreground, #9aa0b0);
  cursor: pointer;

  &:hover {
    background: var(--muted, #f4f6fc);
    color: var(--foreground);
  }
}

.vm-color-picker__modes {
  display: grid;
  flex: 1;
  min-width: 0;
  grid-template-columns: 1fr 1fr;
  gap: 4px;
  margin: 0;
  padding: 3px;
  border-radius: 8px;
  background: var(--muted, #f4f6fc);
}

.vm-color-picker__mode {
  height: 28px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--muted-foreground, #9aa0b0);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;

  &.is-on {
    background: var(--card, #fff);
    color: var(--foreground);
    box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
  }
}

.vm-color-picker__theme {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 280px;
  overflow: auto;
  margin: 0 -4px;
  padding: 0 4px;
}

.vm-color-picker__theme-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  margin: 0;
  padding: 8px;
  border: 1px solid transparent;
  border-radius: 8px;
  background: transparent;
  text-align: left;
  cursor: pointer;
  color: inherit;

  &:hover {
    background: color-mix(in srgb, var(--brand, #4e5dff) 8%, transparent);
  }

  &.is-on {
    border-color: color-mix(in srgb, var(--brand, #4e5dff) 45%, transparent);
    background: color-mix(in srgb, var(--brand, #4e5dff) 10%, transparent);
  }
}

.vm-color-picker__theme-swatch {
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  border-radius: 6px;
  border: 1px solid var(--border, #e8ebf5);
  background-image:
    linear-gradient(45deg, #ccc 25%, transparent 25%),
    linear-gradient(-45deg, #ccc 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #ccc 75%),
    linear-gradient(-45deg, transparent 75%, #ccc 75%);
  background-size: 8px 8px;
  background-position:
    0 0,
    0 4px,
    4px -4px,
    -4px 0;
}

.vm-color-picker__theme-meta {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.vm-color-picker__theme-label {
  font-size: 13px;
  font-weight: 600;
  line-height: 1.2;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.vm-color-picker__theme-key {
  font-size: 11px;
  color: var(--muted-foreground, #9aa0b0);
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.vm-color-picker__sv {
  position: relative;
  width: 100%;
  aspect-ratio: 1;
  border-radius: 6px;
  cursor: crosshair;
  touch-action: none;
  overflow: hidden;
}

.vm-color-picker__sv-cursor {
  position: absolute;
  width: 14px;
  height: 14px;
  margin: -7px 0 0 -7px;
  border: 2px solid #fff;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgb(0 0 0 / 35%);
  pointer-events: none;
}

.vm-color-picker__sliders {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 12px;
}

.vm-color-picker__eyedropper {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--muted);
  color: var(--foreground);
  cursor: pointer;

  &:hover {
    border-color: var(--brand, #4e5dff);
    color: var(--brand, #4e5dff);
  }
}

.vm-color-picker__eyedropper-ico {
  display: block;
}

.vm-color-picker__slider-col {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.vm-color-picker__hue,
.vm-color-picker__alpha {
  position: relative;
  height: 12px;
  border-radius: 999px;
  cursor: pointer;
  touch-action: none;
  /* 允许拇指画出轨道高度，但水平用 thumbLeft 钳在内 */
  overflow: visible;
}

.vm-color-picker__hue {
  background: linear-gradient(
    to right,
    #f00 0%,
    #ff0 17%,
    #0f0 33%,
    #0ff 50%,
    #00f 67%,
    #f0f 83%,
    #f00 100%
  );
}

.vm-color-picker__alpha {
  background-image:
    linear-gradient(45deg, #c8c8c8 25%, transparent 25%),
    linear-gradient(-45deg, #c8c8c8 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #c8c8c8 75%),
    linear-gradient(-45deg, transparent 75%, #c8c8c8 75%);
  background-size: 8px 8px;
  background-position:
    0 0,
    0 4px,
    4px -4px,
    -4px 0;
  background-color: #fff;
}

.vm-color-picker__alpha-fill {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  overflow: hidden;
}

.vm-color-picker__slider-thumb {
  position: absolute;
  top: 50%;
  width: 14px;
  height: 14px;
  transform: translate(-50%, -50%);
  border: 2px solid #fff;
  border-radius: 50%;
  background: transparent;
  box-shadow: 0 0 0 1px rgb(0 0 0 / 35%);
  pointer-events: none;
  box-sizing: border-box;
}

.vm-color-picker__footer {
  position: relative;
  display: flex;
  align-items: center;
  gap: 4px;
  margin-top: 12px;
}

.vm-color-picker__format-wrap {
  position: relative;
  flex: 0 0 auto;
  max-width: 58px;
}

.vm-color-picker__format {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 0;
  width: 100%;
  height: 24px;
  padding: 0 4px 0 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--muted);
  color: var(--foreground);
  font-size: 11px;
  line-height: 1;
  cursor: pointer;

  &:hover,
  &.is-open {
    border-color: var(--brand, #4e5dff);
  }

  svg {
    width: 12px;
    height: 12px;
    flex-shrink: 0;
    opacity: 0.7;
  }
}

.vm-color-picker__format-menu {
  position: absolute;
  left: 0;
  bottom: calc(100% + 4px);
  z-index: 2;
  min-width: 96px;
  padding: 4px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--popover, var(--background, #fff));
  box-shadow:
    0 4px 6px -1px rgb(0 0 0 / 10%),
    0 10px 20px -4px rgb(0 0 0 / 12%);
}

.vm-color-picker__format-item {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  height: 26px;
  padding: 0 8px;
  border: none;
  border-radius: 6px;
  background: transparent;
  color: var(--foreground);
  font-size: 11px;
  text-align: left;
  cursor: pointer;

  &:hover {
    background: color-mix(in srgb, var(--brand, #4e5dff) 12%, transparent);
  }

  &.is-active {
    background: var(--brand, #4e5dff);
    color: #fff;
  }
}

.vm-color-picker__format-check {
  display: inline-flex;
  width: 14px;
  height: 14px;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
}

.vm-color-picker__value,
.vm-color-picker__alpha-pct {
  height: 24px;
  padding: 0 6px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--muted);
  color: var(--foreground);
  font-size: 11px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
  outline: none;

  &:focus {
    border-color: var(--brand, #4e5dff);
  }
}

.vm-color-picker__value {
  flex: 1 1 auto;
  min-width: 0;
}

.vm-color-picker__alpha-pct {
  width: 42px;
  flex: 0 0 42px;
  padding: 0 4px;
  text-align: center;
}
</style>
