"use client"

/**
 * LeoIcon — character-driven Ask Leo icon.
 *
 * Geometry: faithful translation of Figma node 171:1022 (fa-star-christmas).
 * The star is a 4-armed plus/cross with rounded caps (Primary) plus 4
 * diagonal rounded-capsule sparkles in the corners (Secondary, opacity 0.4).
 *
 * ## Motion — `ambient`: four states, one gesture each, one shared clock
 *
 * Motion here is signal, not decoration. The star answers one question without
 * the user reading anything: is Leo asleep, listening, working, or done?
 *
 * | State      | Gesture                                              | Timing |
 * |------------|------------------------------------------------------|--------|
 * | `rest`     | Still                                                | none   |
 * | `invited`  | Anticipate (0.94) → overshoot (1.06) → settle, tilt; sparkles retract then flick out, swept clockwise | one-shot |
 * | `working`  | Traveling wave: sparkles pulse at 90° phase offsets; body breathes on the same beat; whole glyph turns a quarter | wave on `LEO_BEAT`, turn on `LEO_TURN` |
 * | `answered` | Sparkles push out and fade back to rest; body settles from 1.08 | one-shot |
 *
 * Four rules make it legible rather than busy:
 *   1. **One clock.** Every looping value is a multiple of `LEO_BEAT`, so the
 *      composite resolves instead of drifting. The previous motion ran a 6s
 *      breath, an 11s saccade and a 3.2s twinkle against each other, which is
 *      why it read as jitter — the beat never landed.
 *   2. **Deterministic.** Phase comes from the sparkle's index in
 *      `SPARKLE_SWEEP_ORDER`, never `Math.random()`. The same gesture every
 *      time is what makes it learnable instead of noisy.
 *   3. **Even rate while working.** Progress must not ease per cycle, or it
 *      stops reading as progress.
 *   4. **Amplitude scales inversely with size** (`GESTURE_AMPLITUDE`) so a 20px
 *      toggle still reads. Sub-pixel effort at small sizes is pure GPU cost.
 *
 * Reduced motion collapses `rest` / `invited` / `answered` to opacity and drops
 * the `working` loop for a static lit state.
 *
 * variant="ambient"      State-driven chrome — buttons, toggles, badges.
 * variant="interactive"  Cursor-tracking hero (Leo landing, empty state).
 *                        Deliberately outside the state machine.
 */

import * as React from "react"
import {
  animate,
  motion,
  AnimatePresence,
  useMotionValue,
  useSpring,
  useTransform,
  useReducedMotion,
  type Variants,
  type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"

// Readable on light + dark chrome when parent sets --leo-icon-fill (see AskLeoButton).
const LEO_FILL = "var(--leo-icon-fill, var(--brand-color))"

// Glow color for atmospheric layers — follows --leo-icon-fill when set on a parent.
const GLOW = "var(--leo-icon-fill, var(--brand-color))"

// ─── Public API ───────────────────────────────────────────────────────────────

export type LeoIconVariant = "ambient" | "interactive"
export type LeoIconSize = "xs" | "sm" | "md" | "lg" | "xl"

/**
 * What Leo is doing, expressed as motion. Drive it from real state — hover /
 * focus for `invited`, `aria-busy` or a thread's thinking flag for `working`.
 *
 * `answered` is a one-shot: it holds its final frame, so set the state back to
 * `rest` (or `invited`, if the pointer is still over the control) once the
 * resolution beat has played.
 */
export type LeoIconMotionState = "rest" | "invited" | "working" | "answered"

export interface LeoIconProps {
  variant?: LeoIconVariant
  size?: LeoIconSize
  /** Ambient motion state. Ignored by `variant="interactive"`. */
  state?: LeoIconMotionState
  /**
   * Shorthand for parents that only track hover / focus:
   * `true` → `invited`, `false` → `rest`. `state` wins when both are set.
   */
  motionActive?: boolean
  /** Required for `variant="interactive"` — exposed to AT as the control name (WCAG 4.1.2). */
  ariaLabel?: string
  className?: string
  style?: React.CSSProperties
}

// ─── Motion system (ambient) ─────────────────────────────────────────────────

/**
 * The one clock. Every looping duration and every stagger below is this value
 * or a clean division of it, so the four sparkles and the body resolve together
 * on each cycle instead of drifting apart.
 */
const LEO_BEAT = 0.9

/** One-shot gesture length. Long enough to read, short enough to feel crisp. */
const LEO_GESTURE = 0.42

/** Resolution beat — slower than `invited`, because settling should feel earned. */
const LEO_RESOLVE = 0.52

/**
 * One quarter-turn while working — the whole star, sparkles included.
 *
 * 90°, not 360°, because the glyph is 4-fold symmetric: four arms, four corner
 * sparkles. A quarter-turn lands the star exactly on itself, so the loop repeats
 * forever with nothing to reset. A spinner's 360° cycle has a seam at the top
 * where it snaps back; this one has none.
 *
 * Three beats per quarter (2.7s) is deliberately slower than a spinner. A
 * spinner races to say "busy"; a slow turn says "considering", and the sparkle
 * wave on `LEO_BEAT` already carries the tempo. Together they read as one
 * object turning something over rather than two animations sharing a box.
 */
const LEO_TURN = LEO_BEAT * 3

/**
 * Clockwise from top-left. The sweep direction is the whole point: staggering
 * along a path makes the eye read rotation, where simultaneous pulses just
 * read as flicker.
 */
const SPARKLE_SWEEP_ORDER = ["nw", "ne", "se", "sw"] as const

/** Rest opacity of the secondary sparkles, per the Figma spec. */
const SPARKLE_REST_OPACITY = 0.4

/**
 * Small marks need a bigger relative gesture. Sparkle offsets live in SVG user
 * units, which scale linearly with the rendered box, so a gesture tuned on the
 * 80px hero lands sub-pixel on a 20px toggle. This buys the small sizes back.
 */
const GESTURE_AMPLITUDE: Record<LeoIconSize, number> = {
  xs: 1.6,
  sm: 1.35,
  md: 1.15,
  lg: 1,
  xl: 1,
}

/** Anticipation and overshoot — the shape that makes a gesture feel intentional. */
const EASE_GESTURE = [0.34, 1.4, 0.64, 1] as const

type SZ = { root: string; px: number }

const SIZES: Record<LeoIconSize, SZ> = {
  xs: { root: "size-5",  px: 20 },
  sm: { root: "size-8",  px: 32 },
  md: { root: "size-10", px: 40 },
  lg: { root: "size-14", px: 56 },
  xl: { root: "size-20", px: 80 },
}

// ─── Easings ──────────────────────────────────────────────────────────────────

const EASE_BREATH = [0.45, 0.05, 0.2, 1] as const
const EASE_SOFT = [0.22, 1, 0.36, 1] as const

// ─── Geometry (from Figma node 171:1022 — viewBox 0 0 168 168, center 84,84)

const STAR_BODY_PATH =
  "M70 98L31.3906 88.3531C29.4 87.85 28 86.0562 28 84C28 81.9438 29.4 80.15 31.3906 79.6469L70 70L79.6469 31.3906C80.15 29.4 81.9438 28 84 28C86.0562 28 87.85 29.4 88.3531 31.3906L98 70L136.609 79.6469C138.6 80.15 140 81.9438 140 84C140 86.0562 138.6 87.85 136.609 88.3531L98 98L88.3531 136.609C87.85 138.6 86.0562 140 84 140C81.9438 140 80.15 138.6 79.6469 136.609L70 98Z"

interface SparkleCfg {
  id: "ne" | "se" | "sw" | "nw"
  path: string
  /** outward unit vector from center (84,84) */
  diag: readonly [number, number]
  /** stagger phase (seconds) for idle pulsing */
  phase: number
}

const SPARKLES: readonly SparkleCfg[] = [
  {
    id: "nw",
    path: "M43.5313 43.5313C41.475 45.5875 41.475 48.9125 43.5313 50.9469L54.0313 61.4469C56.0875 63.5031 59.4125 63.5031 61.4469 61.4469C63.4813 59.3906 63.5031 56.0656 61.4469 54.0313L50.9688 43.5313C48.9125 41.475 45.5875 41.475 43.5531 43.5313H43.5313Z",
    diag: [-1, -1],
    phase: 2.4,
  },
  {
    id: "sw",
    path: "M43.5313 117.031C41.475 119.087 41.475 122.412 43.5313 124.447C45.5875 126.481 48.9125 126.503 50.9469 124.447L61.4469 113.947C63.5031 111.891 63.5031 108.566 61.4469 106.531C59.3906 104.497 56.0656 104.475 54.0313 106.531L43.5313 117.031Z",
    diag: [-1, 1],
    phase: 1.6,
  },
  {
    id: "ne",
    path: "M106.531 54.0313C104.475 56.0875 104.475 59.4125 106.531 61.4469C108.587 63.4813 111.912 63.5031 113.947 61.4469L124.447 50.9469C126.503 48.8906 126.503 45.5656 124.447 43.5313C122.391 41.4969 119.066 41.475 117.031 43.5313L106.531 54.0313Z",
    diag: [1, -1],
    phase: 0.0,
  },
  {
    id: "se",
    path: "M106.531 106.531C104.475 108.587 104.475 111.912 106.531 113.947L117.031 124.447C119.087 126.503 122.412 126.503 124.447 124.447C126.481 122.391 126.503 119.066 124.447 117.031L113.947 106.531C111.891 104.475 108.566 104.475 106.531 106.531Z",
    diag: [1, 1],
    phase: 0.8,
  },
]

// ─── Ambient state machine ───────────────────────────────────────────────────

/**
 * Star body. `invited` carries the anticipation dip so the overshoot has
 * something to push against — without the dip, a scale-up alone reads as a
 * hover highlight rather than a reaction.
 */
const ambientBodyVariants: Variants = {
  rest: { scale: 1, rotate: 0, transition: { duration: LEO_GESTURE, ease: "easeOut" } },
  invited: {
    scale: [1, 0.94, 1.06, 1],
    rotate: [0, -5, 2, 0],
    transition: { duration: LEO_GESTURE, times: [0, 0.22, 0.62, 1], ease: EASE_GESTURE },
  },
  working: {
    scale: [1, 1.02, 1],
    rotate: 0,
    // Same clock as the sparkle wave, so body and sparkles pulse as one system.
    transition: { duration: LEO_BEAT, repeat: Infinity, ease: "easeInOut" },
  },
  answered: {
    scale: [1.08, 1],
    rotate: 0,
    transition: { duration: LEO_RESOLVE, ease: EASE_SOFT },
  },
}

/**
 * The whole-glyph turn. Lives on a wrapping `<g>` rather than the body path so
 * the sparkles travel with it — a body that spins inside four fixed sparkles
 * reads as a loose part, not a turning object.
 *
 * `rest` / `invited` / `answered` unwind to 0. That unwind is a counter-rotation,
 * which normally reads as rewinding, but here it is bounded to a quarter-turn by
 * the loop, and it lands on the resting upright star — which the icon must return
 * to, because a plus rotated 43° reads as an ✕.
 */
const ambientTurnVariants: Variants = {
  rest: { rotate: 0, transition: { duration: LEO_GESTURE, ease: EASE_SOFT } },
  invited: { rotate: 0, transition: { duration: LEO_GESTURE, ease: EASE_SOFT } },
  working: {
    rotate: 90,
    // Linear, and one clean quarter-turn per repeat — any easing per cycle would
    // add a pulse the sparkle wave is already providing, on a different clock.
    transition: { duration: LEO_TURN, repeat: Infinity, ease: "linear" },
  },
  answered: { rotate: 0, transition: { duration: LEO_RESOLVE, ease: EASE_SOFT } },
}


/**
 * One sparkle's four states. `order` is its index in `SPARKLE_SWEEP_ORDER` and
 * drives every stagger, so the sweep direction is identical on every play.
 */
function ambientSparkleVariants(
  diag: readonly [number, number],
  order: number,
  amp: number,
): Variants {
  const [dx, dy] = diag
  const out = (units: number) => units * amp
  // Quarter-beat offsets put the four sparkles 90° apart around the cycle.
  const wavePhase = (LEO_BEAT / 4) * order

  return {
    rest: {
      opacity: SPARKLE_REST_OPACITY,
      scale: 1,
      x: 0,
      y: 0,
      transition: { duration: LEO_GESTURE, ease: "easeOut" },
    },
    invited: {
      // Retract toward the body, then flick outward along its own diagonal.
      // The wind-up is what reads as intent; a straight push outward reads as
      // a size change.
      x: [0, out(-3) * dx, out(5) * dx, 0],
      y: [0, out(-3) * dy, out(5) * dy, 0],
      opacity: [SPARKLE_REST_OPACITY, 0.55, 1, 0.72],
      scale: [1, 0.9, 1.18, 1],
      transition: {
        duration: LEO_GESTURE,
        delay: order * 0.04,
        times: [0, 0.24, 0.6, 1],
        ease: EASE_GESTURE,
      },
    },
    working: {
      opacity: [0.3, 1, 0.3],
      scale: [0.92, 1.2, 0.92],
      x: [0, out(4) * dx, 0],
      y: [0, out(4) * dy, 0],
      transition: {
        duration: LEO_BEAT,
        repeat: Infinity,
        delay: wavePhase,
        // Linear: an eased cycle stops reading as steady progress.
        ease: "linear",
      },
    },
    answered: {
      x: [0, out(11) * dx],
      y: [0, out(11) * dy],
      opacity: [1, SPARKLE_REST_OPACITY],
      scale: [1.2, 1],
      transition: { duration: LEO_RESOLVE, delay: order * 0.03, ease: EASE_SOFT },
    },
  }
}

/** Reduced motion: keep the state legible, drop the movement. */
const ambientSparkleReducedVariants: Variants = {
  rest: { opacity: SPARKLE_REST_OPACITY, scale: 1, x: 0, y: 0 },
  invited: { opacity: 0.72, scale: 1, x: 0, y: 0 },
  working: { opacity: 1, scale: 1, x: 0, y: 0 },
  answered: { opacity: SPARKLE_REST_OPACITY, scale: 1, x: 0, y: 0 },
}

/**
 * Ambient star. Separate from {@link LeoStarSVG} on purpose: this one is a pure
 * state machine with no cursor plumbing, no mount birth, and no press squash,
 * so the two motion models stay independently readable.
 */
function AmbientStarSVG({
  px,
  sizeClass,
  state,
  amp,
  reduced,
}: {
  px: number
  /** Must include a `size-*` utility so Button's `[&_svg:not([class*='size-'])]:size-4` does not crush the glyph. */
  sizeClass: string
  state: LeoIconMotionState
  amp: number
  reduced: boolean
}) {
  const sparkles = React.useMemo(
    () =>
      SPARKLE_SWEEP_ORDER.map((id, order) => {
        const cfg = SPARKLES.find(s => s.id === id)!
        return { cfg, variants: ambientSparkleVariants(cfg.diag, order, amp) }
      }),
    [amp],
  )

  return (
    <svg
      width={px}
      height={px}
      viewBox="0 0 168 168"
      aria-hidden
      className={sizeClass}
      style={{ overflow: "visible", display: "block" }}
    >
      {/* Turn group. `view-box` origin, not `fill-box`: the sparkles move during
          the working wave, so a fill-box origin would track their shifting
          bounding box and wobble the axis. The viewBox centre never moves. */}
      <motion.g
        style={{ transformBox: "view-box", transformOrigin: "center" }}
        variants={ambientTurnVariants}
        initial="rest"
        animate={reduced ? "rest" : state}
      >
        {sparkles.map(({ cfg, variants }) => (
          <motion.path
            key={cfg.id}
            d={cfg.path}
            fill={LEO_FILL}
            style={{ transformBox: "fill-box", transformOrigin: "center" }}
            variants={reduced ? ambientSparkleReducedVariants : variants}
            initial="rest"
            animate={state}
          />
        ))}

        <motion.path
          d={STAR_BODY_PATH}
          fill={LEO_FILL}
          style={{ transformBox: "fill-box", transformOrigin: "center" }}
          variants={ambientBodyVariants}
          initial="rest"
          animate={reduced ? "rest" : state}
        />
      </motion.g>
    </svg>
  )
}

// ─── Variants (interactive) ──────────────────────────────────────────────────

// Star body: always breathes + saccades. Never hover-popped — cursor reactions
// live on the outer wrapper and compose via nested transforms.
const starBodyVariants: Variants = {
  idle: {
    scale:  [1, 1.032, 1, 1.02, 1],
    rotate: [0, 0, 2, 0, 0, -2.4, 0, 0, 1.2, 0, 0],
    transition: {
      scale: {
        duration: 6, repeat: Infinity, ease: EASE_BREATH,
        times: [0, 0.25, 0.5, 0.75, 1],
      },
      rotate: {
        duration: 11, repeat: Infinity, ease: "easeOut",
        times: [0, 0.18, 0.20, 0.26, 0.46, 0.48, 0.55, 0.74, 0.76, 0.83, 1],
      },
    },
  },
  // Matches ambientBodyVariants.invited — FAB / hover one-shot.
  invited: {
    scale: [1, 0.94, 1.06, 1],
    rotate: [0, -5, 2, 0],
    transition: { duration: LEO_GESTURE, times: [0, 0.22, 0.62, 1], ease: EASE_GESTURE },
  },
}

// Sparkle inner: idle twinkle + the same one-shot `invited` gesture as ambient/FAB.
const sparkleInnerVariantsFor = (
  phase: number, diag: readonly [number, number], order: number,
): Variants => {
  const [dx, dy] = diag
  const out = (units: number) => units // xl hero amp = 1 (matches GESTURE_AMPLITUDE.xl)
  return {
    idle: {
      opacity: [0.75, 1, 0.75, 0.9, 0.75],
      scale:   [0.92, 1.08, 0.92, 1.02, 0.92],
      x: 0, y: 0,
      transition: {
        duration: 3.2, delay: phase, repeat: Infinity, ease: "easeInOut",
      },
    },
    // Same retract → flick as ambientSparkleVariants.invited (FAB / hover).
    invited: {
      x: [0, out(-3) * dx, out(5) * dx, 0],
      y: [0, out(-3) * dy, out(5) * dy, 0],
      opacity: [SPARKLE_REST_OPACITY, 0.55, 1, 0.72],
      scale: [1, 0.9, 1.18, 1],
      transition: {
        duration: LEO_GESTURE,
        delay: order * 0.04,
        times: [0, 0.24, 0.6, 1],
        ease: EASE_GESTURE,
      },
    },
  }
}

const SPARKLE_VARIANTS_BY_ID: Record<SparkleCfg["id"], Variants> = {
  nw: sparkleInnerVariantsFor(2.4, [-1, -1], 0),
  ne: sparkleInnerVariantsFor(0.0, [ 1, -1], 1),
  se: sparkleInnerVariantsFor(0.8, [ 1,  1], 2),
  sw: sparkleInnerVariantsFor(1.6, [-1,  1], 3),
}

// ─── Per-sparkle directional response to cursor ──────────────────────────────
// Outer <g> wraps the sparkle. Its style reacts to how aligned the cursor is
// with this sparkle's outward direction. Sparkles in the cursor's direction
// brighten, grow, and lean outward; others stay at their base opacity.
// `bornAmount` (0→1) scales the base opacity during the birth animation so
// sparkles bloom in *after* the main body materializes.

function CornerSparkle({
  c, reduced, invited, inviteKey, mx, my, bornAmount,
}: {
  c: SparkleCfg
  reduced: boolean
  invited: boolean
  /** Bumps so a second click replays the gesture while still invited. */
  inviteKey: number
  mx: MotionValue<number>
  my: MotionValue<number>
  bornAmount: MotionValue<number>
}) {
  // Unit vector in the sparkle's outward direction.
  const sx = c.diag[0] / Math.SQRT2
  const sy = c.diag[1] / Math.SQRT2

  // Alignment: how much the cursor vector points at this sparkle. Range [0, 1].
  // Combines direction (dot product with sparkle's outward vector) with
  // proximity magnitude so distant cursors barely register.
  const align = useTransform([mx, my] as MotionValue<number>[], ([x, y]) => {
    const mag = Math.hypot(x as number, y as number)
    if (mag < 0.01) return 0
    const dot = ((x as number) * sx + (y as number) * sy) / mag
    const magScale = Math.min(1, mag * 2) // mag range [0, 0.5] → [0, 1]
    return Math.max(0, Math.min(1, dot * magScale))
  })

  // Spring the alignment so the reaction feels organic, not snappy.
  const sprAlign = useSpring(align, { stiffness: 180, damping: 26, mass: 0.5 })

  // Derived outer-group reactions — multiplied by bornAmount so sparkles are
  // invisible during body birth, then fade in.
  const outerOpacity = useTransform(
    [sprAlign, bornAmount] as MotionValue<number>[],
    ([a, b]) => (0.4 + (a as number) * 0.55) * (b as number),
  )
  const outerScale = useTransform(sprAlign, v => 1 + v * 0.35)
  const outerX = useTransform(sprAlign, v => c.diag[0] * v * 6)
  const outerY = useTransform(sprAlign, v => c.diag[1] * v * 6)

  return (
    <motion.g
      style={{
        opacity: reduced ? 0.4 : outerOpacity,
        scale:   reduced ? 1   : outerScale,
        x:       reduced ? 0   : outerX,
        y:       reduced ? 0   : outerY,
        transformBox: "fill-box",
        transformOrigin: "center",
      }}
    >
      <motion.path
        key={invited ? `inv-${inviteKey}` : "idle"}
        d={c.path}
        fill={LEO_FILL}
        style={{ transformBox: "fill-box", transformOrigin: "center" }}
        variants={SPARKLE_VARIANTS_BY_ID[c.id]}
        animate={reduced ? undefined : invited ? "invited" : "idle"}
      />
    </motion.g>
  )
}

// ─── Birth animation — "from a single point, a star" ────────────────────────
// Outer wrapper plays on mount: starts as a scale-0 bright-blurry pinpoint
// and blooms into a crisp star. Runs once, then sits at its resting state.

const birthVariants: Variants = {
  hidden: {
    scale: 0,
    opacity: 0,
    filter: "blur(4px)",
  },
  live: {
    scale: [0, 0.12, 1.04, 1],
    opacity: [0, 1, 1, 1],
    filter: ["blur(4px)", "blur(2.2px)", "blur(0px)", "blur(0px)"],
    transition: {
      duration: 0.9,
      times: [0, 0.18, 0.78, 1],
      ease: [0.2, 0.8, 0.2, 1],
    },
  },
}

// ─── Core SVG — 2D only. Cursor reactions on the inner wrapper. ──────────────

const LEO_STAR_TILT_CFG = { stiffness: 200, damping: 22, mass: 0.55 }

function LeoStarSVG({
  px, sizeClass, reduced, pressed, invited, inviteKey, mx, my, engage, motionLive = true, skipBirth = false,
}: {
  px: number
  /** Must include a `size-*` utility so Button's `[&_svg:not([class*='size-'])]:size-4` does not crush the glyph. */
  sizeClass: string
  reduced: boolean
  pressed: boolean
  invited: boolean
  inviteKey: number
  mx: MotionValue<number>
  my: MotionValue<number>
  engage: MotionValue<number>
  /** When false, star sits static (no breath / birth). */
  motionLive?: boolean
  /** Hover-driven CTAs — jump straight to idle without mount birth. */
  skipBirth?: boolean
}) {
  // 2D reactions — tight but subtle. No 3D space at all.
  const rotZ = useSpring(useTransform(mx, [-0.5, 0.5], [-10, 10]), LEO_STAR_TILT_CFG)
  const shiftX = useSpring(useTransform(mx, [-0.5, 0.5], [-6, 6]), LEO_STAR_TILT_CFG)
  const shiftY = useSpring(useTransform(my, [-0.5, 0.5], [-6, 6]), LEO_STAR_TILT_CFG)

  // Proximity scale driven by `engage` spring (0 → 1 on hover in, decays on out).
  const proxScale = useTransform(engage, [0, 1], [1, 1.1])

  // Quick click squash on the star body (composed with idle breath via nested g).
  const pressScale = useSpring(pressed ? 0.92 : 1, {
    stiffness: 380, damping: 26, mass: 0.4,
  })

  // Birth → live handoff. Once born, sparkles are allowed to appear.
  // Hover-driven CTAs (`skipBirth`) stay ready — no mount bloom on every hover.
  const bornAmount = useMotionValue(reduced || skipBirth || !motionLive ? 1 : 0)
  React.useEffect(() => {
    if (reduced || skipBirth || !motionLive) { bornAmount.set(1); return }
    const controls = animate(bornAmount, 1, {
      duration: 0.55, delay: 0.4, ease: [0.22, 1, 0.36, 1],
    })
    return () => controls.stop()
  }, [bornAmount, reduced, motionLive, skipBirth])

  const runIdle = motionLive && !reduced

  return (
    // Outer: birth animation (runs once on mount when always-on ambient)
    <motion.span
      style={{ display: "inline-flex" }}
      variants={birthVariants}
      initial={reduced || skipBirth || !motionLive ? false : "hidden"}
      animate="live"
    >
      {/* Inner: cursor reactions (always active) */}
      <motion.span
        style={{
          display: "inline-flex",
          rotate: reduced ? 0 : rotZ,
          scale:  reduced ? 1 : proxScale,
          x:      reduced ? 0 : shiftX,
          y:      reduced ? 0 : shiftY,
        }}
      >
        <svg
          width={px}
          height={px}
          viewBox="0 0 168 168"
          aria-hidden
          className={sizeClass}
          style={{ overflow: "visible", display: "block" }}
        >
          {/* 4 corner sparkles — each reacts to cursor direction independently */}
          {SPARKLES.map(c => (
            <CornerSparkle
              key={c.id}
              c={c}
              reduced={reduced || !motionLive}
              invited={invited}
              inviteKey={inviteKey}
              mx={mx}
              my={my}
              bornAmount={bornAmount}
            />
          ))}

          {/* Star body — breath + saccades always running.
              Wrapped in <motion.g> so click squash composes with breath scale. */}
          <motion.g
            style={{
              scale: reduced ? 1 : pressScale,
              transformBox: "fill-box",
              transformOrigin: "center",
            }}
          >
            <motion.path
              key={invited ? `body-inv-${inviteKey}` : "body-idle"}
              d={STAR_BODY_PATH}
              fill={LEO_FILL}
              style={{ transformBox: "fill-box", transformOrigin: "center" }}
              variants={starBodyVariants}
              animate={reduced || !runIdle ? undefined : invited ? "invited" : "idle"}
            />
          </motion.g>
        </svg>
      </motion.span>
    </motion.span>
  )
}

// ─── Twinkle system (external firefly sparkles around the star) ──────────────

interface Twinkle {
  id: number
  x: number; y: number
  dx: number; dy: number
  size: number
  rot: number
  dur: number
}

function TwinkleShape({ size }: { size: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 16 16"
      aria-hidden
      // `size-px` opts out of Button's `[&_svg:not([class*='size-'])]:size-4`;
      // inline width/height set the real sparkle size.
      className="size-px"
      style={{ width: size, height: size, display: "block" }}
    >
      <path
        d="M8 0 L9.5 6.5 L16 8 L9.5 9.5 L8 16 L6.5 9.5 L0 8 L6.5 6.5 Z"
        fill={LEO_FILL}
      />
    </svg>
  )
}

function TwinkleDot({ t, onDone }: { t: Twinkle; onDone: (id: number) => void }) {
  return (
    <motion.span
      aria-hidden
      className="pointer-events-none absolute"
      style={{ top: "50%", left: "50%", rotate: t.rot }}
      initial={{ x: t.x, y: t.y, scale: 0.01, opacity: 0 }}
      animate={{
        x: t.x + t.dx,
        y: t.y + t.dy,
        scale:   [0.01, 1, 0.85, 0],
        opacity: [0, 1, 0.9, 0],
      }}
      transition={{
        duration: t.dur,
        scale:   { duration: t.dur, times: [0, 0.28, 0.65, 1], ease: EASE_SOFT },
        opacity: { duration: t.dur, times: [0, 0.28, 0.65, 1], ease: EASE_SOFT },
        x: { duration: t.dur, ease: "easeOut" },
        y: { duration: t.dur, ease: "easeOut" },
      }}
      onAnimationComplete={() => onDone(t.id)}
    >
      <TwinkleShape size={t.size} />
    </motion.span>
  )
}

/** Spark cadence for the hero — leisurely at rest, lively under the cursor. */
const TWINKLE_TIMING_MS = {
  idleMin: 2800,
  idleMax: 5800,
  hoverMin: 280,
  hoverMax: 680,
  initialSpread: 700,
} as const

/**
 * Firefly sparks for the `interactive` hero only. The ambient variant used to
 * share this and it was the single biggest reason its motion read as noise —
 * random angle, size, duration and delay meant no gesture ever repeated.
 */
function useTwinkles(
  enabled: boolean,
  size: number,
  opts: {
    hoverRef?: React.MutableRefObject<boolean>
    cursorRef?: React.MutableRefObject<{ x: number; y: number } | null>
  } = {},
) {
  const [twinkles, setTwinkles] = React.useState<Twinkle[]>([])
  const idRef = React.useRef(0)
  const { hoverRef, cursorRef } = opts

  const spawnOne = React.useCallback(() => {
    const hovered = hoverRef?.current ?? false
    const cursor = cursorRef?.current ?? null
    const radius = size * (0.34 + Math.random() * 0.30)

    let angle: number
    if (cursor) {
      const base = Math.atan2(cursor.y, cursor.x)
      angle = base + (Math.random() - 0.5) * Math.PI * 0.55
    } else {
      angle = Math.random() * Math.PI * 2
    }

    const x = Math.cos(angle) * radius
    const y = Math.sin(angle) * radius
    const drift = size * 0.09
    const sparkSize = 3 + Math.random() * (hovered ? 4 : 2.5)

    // Claim the id before queueing, not inside the updater — the updater has
    // to be replay-safe, and bumping a ref from it is not.
    const id = idRef.current++

    setTwinkles(prev => [...prev, {
      id,
      x, y,
      dx: Math.cos(angle) * drift,
      dy: Math.sin(angle) * drift,
      size: sparkSize,
      rot: (Math.random() - 0.5) * 60,
      dur: 1.2 + Math.random() * 0.9,
    }])
  }, [size, hoverRef, cursorRef])

  React.useEffect(() => {
    if (!enabled) return
    let cancelled = false
    let timeoutId: ReturnType<typeof setTimeout>

    const schedule = () => {
      const hovered = hoverRef?.current ?? false
      const min = hovered ? TWINKLE_TIMING_MS.hoverMin : TWINKLE_TIMING_MS.idleMin
      const max = hovered ? TWINKLE_TIMING_MS.hoverMax : TWINKLE_TIMING_MS.idleMax
      const delay = min + Math.random() * (max - min)
      timeoutId = setTimeout(() => {
        if (cancelled) return
        spawnOne()
        schedule()
      }, delay)
    }

    timeoutId = setTimeout(() => {
      if (cancelled) return
      spawnOne()
      schedule()
    }, 120 + Math.random() * TWINKLE_TIMING_MS.initialSpread)

    return () => { cancelled = true; clearTimeout(timeoutId) }
  }, [enabled, spawnOne, hoverRef])

  const removeTwinkle = React.useCallback((id: number) => {
    setTwinkles(prev => prev.filter(t => t.id !== id))
  }, [])

  return { twinkles, removeTwinkle }
}

// ─── Ambient variant ─────────────────────────────────────────────────────────

function AmbientIcon({
  sz,
  size,
  reduced,
  state,
}: {
  sz: SZ
  size: LeoIconSize
  reduced: boolean
  state: LeoIconMotionState
}) {
  return (
    <span
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center overflow-visible",
        sz.root,
      )}
    >
      {/* No aura and no firefly spawner here. The pulsing radial blob read as a
          stray glow behind the label, and randomly spawned sparks were the main
          reason the motion felt like noise: nothing the user saw twice was ever
          the same. Atmosphere belongs to the hero `interactive` variant. */}

      {/* `flex`, not the default block: an inline-flex SVG in a block box sits
          on the text baseline, so the font descent padded this wrapper to 25px
          and lifted the star ~2.5px above the optical centre of its 20px slot. */}
      <span className="relative z-10 flex items-center justify-center">
        <AmbientStarSVG
          px={sz.px}
          sizeClass={sz.root}
          state={state}
          amp={GESTURE_AMPLITUDE[size]}
          reduced={reduced}
        />
      </span>
    </span>
  )
}

// ─── Interactive variant ─────────────────────────────────────────────────────

function InteractiveIcon({
  sz,
  reduced,
  ariaLabel = "Ask Leo",
}: {
  sz: SZ
  reduced: boolean
  ariaLabel: string
}) {
  const rootRef = React.useRef<HTMLSpanElement>(null)
  const hoverRef = React.useRef(false)
  const cursorRef = React.useRef<{ x: number; y: number } | null>(null)
  const [pressed, setPressed] = React.useState(false)
  const [invited, setInvited] = React.useState(false)
  const [inviteKey, setInviteKey] = React.useState(0)

  const { twinkles, removeTwinkle } = useTwinkles(
    !reduced,
    sz.px,
    { hoverRef, cursorRef },
  )

  const mx = useMotionValue(0)
  const my = useMotionValue(0)
  const engage = useSpring(0, { stiffness: 170, damping: 25 })

  const auraOpacity = useTransform(engage, [0, 1], [0.03, 0.07])
  const auraScale = useTransform(engage, [0, 1], [0.92, 1.08])

  // Viewport-wide cursor awareness.
  // While mounted, Leo watches the entire window. Cursor position relative to
  // the star's center drives mx/my (direction) and engage (proximity).
  // The farther the cursor, the smaller the response — exponential falloff.
  React.useEffect(() => {
    if (reduced) return
    let rafId = 0

    const onMove = (e: MouseEvent) => {
      if (rafId) return // coalesce to one update per frame
      rafId = requestAnimationFrame(() => {
        rafId = 0
        const node = rootRef.current
        if (!node) return
        const rect = node.getBoundingClientRect()
        const cx = rect.left + rect.width / 2
        const cy = rect.top + rect.height / 2
        const dx = e.clientX - cx
        const dy = e.clientY - cy
        const dist = Math.hypot(dx, dy)
        const radius = rect.width / 2

        // Unit direction vector from star center to cursor.
        const dirX = dist > 1 ? dx / dist : 0
        const dirY = dist > 1 ? dy / dist : 0

        // Proximity: 1 when cursor is on the star, falls off exponentially
        // past the star's edge. Half-life ≈ 195 px.
        const edgeDist = Math.max(0, dist - radius)
        const prox = Math.exp(-edgeDist / 280)

        // Encode direction × proximity so mx/my naturally attenuate with distance.
        mx.set(dirX * 0.5 * prox)
        my.set(dirY * 0.5 * prox)
        cursorRef.current = { x: dirX * prox, y: dirY * prox }
        engage.set(prox)
        hoverRef.current = prox > 0.45
      })
    }

    // Reset when cursor exits the document entirely.
    const onDocLeave = () => {
      mx.set(0); my.set(0)
      cursorRef.current = null
      engage.set(0)
      hoverRef.current = false
    }

    window.addEventListener("mousemove", onMove, { passive: true })
    document.addEventListener("mouseleave", onDocLeave)

    return () => {
      if (rafId) cancelAnimationFrame(rafId)
      window.removeEventListener("mousemove", onMove)
      document.removeEventListener("mouseleave", onDocLeave)
    }
  }, [mx, my, engage, reduced])

  const onDown = React.useCallback(() => setPressed(true), [])
  const onUp = React.useCallback(() => setPressed(false), [])

  // Track click-effect timers so unmounting (Ask Leo sidebar close) doesn't
  // leave timers running that then call setState on an unmounted component.
  const clickTimersRef = React.useRef<Set<ReturnType<typeof setTimeout>>>(undefined)

  const clickTimers = () => {
    if (!clickTimersRef.current) clickTimersRef.current = new Set()
    return clickTimersRef.current
  }

  React.useEffect(() => {
    const set = clickTimers()
    return () => {
      for (const t of set) clearTimeout(t)
      set.clear()
    }
  }, [])

  const onClick = React.useCallback(() => {
    if (reduced) return
    // Same one-shot `invited` gesture as ambient FAB / Ask Leo open — not
    // scatter rings or a twinkle burst (those changed the star after click).
    setInviteKey(k => k + 1)
    setInvited(true)
    engage.set(1)
    const timers = clickTimers()
    const tInvite = setTimeout(() => {
      timers.delete(tInvite)
      setInvited(false)
    }, 520)
    timers.add(tInvite)
  }, [reduced, engage])

  const onIconKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLSpanElement>) => {
    if (e.key !== "Enter" && e.key !== " ") return
    e.preventDefault()
    onClick()
  }, [onClick])

  return (
    <span
      ref={rootRef}
      role="button"
      tabIndex={0}
      aria-label={ariaLabel}
      data-leo-icon-interactive=""
      className={cn(
        "relative inline-flex items-center justify-center shrink-0 cursor-pointer select-none",
        sz.root,
      )}
      onMouseDown={onDown}
      onMouseUp={onUp}
      onClick={onClick}
      onKeyDown={onIconKeyDown}
    >
      {/* Breathing aura — subtle background presence */}
      <motion.span
        aria-hidden
        className="pointer-events-none absolute inset-[-22%] rounded-full"
        style={{
          background: `radial-gradient(circle, ${GLOW} 0%, transparent 65%)`,
          opacity: reduced ? 0.04 : auraOpacity,
          scale: reduced ? 1 : auraScale,
        }}
      />

      {/* Firefly twinkles — biased toward cursor direction */}
      <AnimatePresence>
        {twinkles.map(t => (
          <TwinkleDot key={t.id} t={t} onDone={removeTwinkle} />
        ))}
      </AnimatePresence>

      <LeoStarSVG
        px={sz.px}
        sizeClass={sz.root}
        reduced={reduced}
        pressed={pressed}
        invited={invited}
        inviteKey={inviteKey}
        mx={mx}
        my={my}
        engage={engage}
      />
    </span>
  )
}

// ─── Public export ───────────────────────────────────────────────────────────

/**
 * Animated Ask Leo icon.
 *
 * @example
 * // Ambient — still at rest, reacts on hover/focus, loops only while working
 * <LeoIcon variant="ambient" size="xs" state={busy ? "working" : hovered ? "invited" : "rest"} />
 *
 * // Hover/focus only — shorthand for `invited` / `rest`
 * <LeoIcon variant="ambient" size="xs" motionActive={hovered} />
 *
 * // Interactive — cursor-aware, for hero/welcome surfaces
 * <LeoIcon variant="interactive" size="xl" />
 */
export function LeoIcon({
  variant = "ambient",
  size = "md",
  state,
  motionActive,
  ariaLabel = "Ask Leo",
  className,
  style,
}: LeoIconProps) {
  const reduced = useReducedMotion() ?? false
  const sz = SIZES[size]
  // Explicit state wins; `motionActive` is the hover-only shorthand. With
  // neither, the icon sits still — chrome should not loop unprompted.
  const motionState: LeoIconMotionState =
    state ?? (motionActive ? "invited" : "rest")

  return (
    <span
      className={cn("inline-flex items-center justify-center", className)}
      style={style}
    >
      {variant === "interactive"
        ? <InteractiveIcon sz={sz} reduced={reduced} ariaLabel={ariaLabel} />
        : <AmbientIcon
            sz={sz}
            size={size}
            reduced={reduced}
            state={motionState}
          />}
    </span>
  )
}
