<script lang="ts">
  // The desktop mascot sprite: a small canvas that renders the current
  // animation frame at ~30fps (via setTimeout, not rAF, for hidden-tab
  // embedding safety — see LOOP_MS below), and handles pointer drag,
  // plain-click (pet), and right-click (open the radial menu).
  // Position/physics live in MascotRuntime, owned here; long-lived
  // tamagotchi state lives in state.svelte.ts.
  //
  // The mascot renders above the window layer (z-45 via MascotLayer) but
  // its pointer hitbox is exactly the canvas element — no oversized
  // invisible padding — so it only occludes clicks on window chrome
  // directly beneath the sprite, per the "renders above windows" design
  // decision in plans/2026-07-20-desktop-mascot.md.

  import { onMount } from 'svelte'
  import { loadSprites, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
  import { drawFrame, CANVAS_W, CANVAS_H } from '$lib/mascot/render'
  import { stepMascot, forceBehavior, releaseFromDrag, reground } from '$lib/mascot/behavior'
  import type { MascotRuntime, MascotStage, AnimName } from '$lib/mascot/types'
  import {
    initMascotState,
    getModel,
    tickLifecycle,
    advanceStageIfReady,
    setLastPos,
    pet as modelPet
  } from '$lib/mascot/state.svelte'
  import { wmState } from '$lib/stores/windows'
  import { getIconPositions, iconPixelPos, GRID } from '$lib/stores/icons'

  // MascotRuntime is created fresh per mount; the long-lived MascotModel
  // (with lastPos, stage, name, ...) persists across mounts via localStorage.
  // `runtime` is $bindable(): this component mutates it constantly (every
  // tick, every pointer event) rather than treating it as read-only input,
  // which Svelte 5 flags as an "ownership_invalid_mutation" dev warning
  // unless the prop is declared bindable and the parent uses bind:runtime.
  let {
    runtime = $bindable(),
    onPet,
    onRequestName
  }: {
    runtime: MascotRuntime
    onPet: () => void
    /** Called on a plain click while the mascot is a not-yet-named egg — reopens the naming dialog (see NameDialog's Escape-dismiss). */
    onRequestName: () => void
  } = $props()

  const SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
  const DRAG_THRESHOLD = 5

  // Ground tracking (see behavior.ts's comment block for the fuller
  // rationale): a small per-tick ground change (a window being dragged
  // smoothly, with the mascot riding along) follows instantly; a bigger
  // drop hands off to `falling` instead of snapping the mascot's position.
  const GROUND_FOLLOW_MAX_STEP = 6 // px/tick the mascot may instantly follow a rising/shifting ground
  const GROUND_DROP_FALL_PX = 12 // px — a ground drop bigger than this triggers falling, not a snap
  // A window is only a ground candidate if its top is at/below the
  // mascot's CURRENT position (a downward raycast from where it stands) —
  // otherwise a window opening/moving anywhere in its column, even one
  // that never gets near it, would yank it upward onto that window's top.
  const GROUND_CANDIDATE_EPSILON = 4

  // Toss momentum: a short rolling window of recent pointer-move samples
  // during a drag, used to derive a release velocity (onPointerUp) instead
  // of always dropping straight down from wherever the pointer let go.
  const VELOCITY_WINDOW_MS = 120
  const TOSS_MAX_VX = 900 // px/s
  const TOSS_MAX_UPWARD_VY = 700 // px/s (a hard upward flick can toss it up a bit before gravity wins)
  const TOSS_MAX_DOWNWARD_VY = 400 // px/s (don't let a fast downward fling outrun the fall's own gravity feel)
  // 60fps for the sprite loop: the mascot has fast motion (drag, fall), and
  // 30fps position updates look choppy on 60Hz+ displays. setTimeout, not
  // rAF: some embedding contexts report document.hidden=true and suspend
  // rAF; setTimeout keeps ticking. dt is clamped below so a
  // throttled/backgrounded tab doesn't produce a physics-breaking huge
  // step on resume.
  const LOOP_MS = 16
  const DT_CLAMP_MS = 100
  const LIFECYCLE_TICK_MS = 1000

  // ── Juice (render-layer feel) ────────────────────────────────────────
  // None of this touches the physics state — it only reshapes how the
  // physics READS on screen. Computed each tick into the juice* $state
  // below and composed into the sprite's CSS transform / drawn onto the
  // canvas:
  //  - Squash-and-stretch: a damped-spring squash on every ground impact
  //    (depth scaled by impactVy), plus an in-air stretch proportional
  //    to fall speed (volume roughly preserved: sx opposes sy).
  //  - Air tilt: the sprite leans into horizontal motion while falling,
  //    being dragged, or skidding.
  //  - Walk bob: a small vertical step bounce while wandering.
  //  - Feather poof: a burst of tiny pixels on hard impacts.
  const SPRING_WINDOW_S = 0.45 // how long the impact squash spring oscillates
  const SPRING_FREQ_HZ = 3
  const SPRING_DECAY = 6.5
  const MAX_SQUASH_DEPTH = 0.32
  const POOF_MIN_VY = 280 // impact speed below which no feather/dust poof spawns
  const FEATHER_GRAVITY = 550 // px/s^2 — floaty, lighter than the mascot
  const FEATHER_TINTS = ['#ffffff', '#fef3c7', '#fde68a']

  let canvas = $state<HTMLCanvasElement | null>(null)
  let ctx2d: CanvasRenderingContext2D | null = null
  let timer: ReturnType<typeof setTimeout> | 0 = 0
  let lastFrame = 0
  let lastLifecycle = 0
  let dragging = $state(false)
  let dragPointerId: number | null = null
  let dragStartClient = { x: 0, y: 0 }
  let moved = false
  // Recent pointer-move samples during a drag (surface coords + timestamp),
  // trimmed to the last VELOCITY_WINDOW_MS — used to derive a release
  // (toss) velocity in onPointerUp.
  let dragSamples: { t: number; x: number; y: number }[] = []
  // Wiggle phase for the egg (render-time only, not persisted).
  let wigglePhase = 0
  // Juice render state, recomputed every tick (see the constants above).
  let juiceSx = $state(1)
  let juiceSy = $state(1)
  let juiceTilt = $state(0) // deg
  let juiceBob = $state(0) // px, <= 0 (raises the sprite)
  let lastDtMs = 16 // last tick's dt, reused by the feather integrator in renderSprite
  // Feather-poof particles: plain array, integrated + painted imperatively
  // on the canvas (no reactivity needed). Positions are surface coords so
  // the poof hangs where the impact happened, not on the sprite.
  interface Feather {
    x: number
    y: number
    vx: number
    vy: number
    born: number
    life: number
    size: number
    tint: string
  }
  let feathers: Feather[] = []
  let lastSquashAt = 0 // last squashAt we spawned a poof for
  // Snapshot of the current window manager state, refreshed by
  // subscription. Read inside the tick to compute the ground line at the
  // mascot's x — the highest non-minimized window top edge beneath it,
  // or the surface bottom when no window is beneath.
  let currentWindows: typeof $wmState = { order: [], windows: {}, focusedId: null }
  // Previous ground y, used to detect when the window beneath the
  // mascot moved so the mascot can ride along (stick to the ground)
  // instead of floating in place while the window drifts out from
  // under it.
  let prevGroundY = 0

  const model = $derived(getModel())

  function stage(): MascotStage {
    return model.stage
  }

  /**
   * Compute the ground line at the mascot's x: the top edge (y) of the
   * NEAREST non-minimized window whose horizontal span covers the
   * mascot's x AND whose top is at/below the mascot's current position —
   * i.e. a downward raycast from where it's standing, not "the topmost
   * window anywhere in this column." Without the vertical check, a window
   * opening or moving anywhere above the mascot (even with empty space
   * between its underside and the mascot) would be picked as ground and
   * the ride-along logic in tick() would yank the mascot up onto it
   * instantly — this was a confirmed bug (see
   * plans/2026-07-20-mascot-physics-audit.md, Finding 1 case B).
   * Falls back to the surface bottom (bounds.h) when nothing qualifies.
   * This is what lets the mascot walk ON TOP of windows — when it strolls
   * over one, the ground rises to its top edge; when it walks off the
   * side (or the window moves/closes), the ground drops and tick()'s
   * chase logic hands off to a real `falling` behavior.
   */
  function computeGroundAt(x: number): number {
    let ground = runtime.bounds.h
    for (const id of currentWindows.order) {
      const win = currentWindows.windows[id]
      if (!win || win.stage === 'minimized') continue
      const b = win.bounds
      // Window top edge counts as ground only if the mascot's x is
      // within the window's horizontal span (with a small margin so the
      // mascot doesn't immediately fall off the very corner)...
      const inSpan = x >= b.x - 4 && x <= b.x + b.width + 4
      // ...AND the window's top is at/below where the mascot currently
      // is (it's a real surface underfoot, not a window floating above
      // with a gap beneath it).
      const atOrBelow = b.y >= runtime.y - GROUND_CANDIDATE_EPSILON
      if (inSpan && atOrBelow && b.y < ground) {
        ground = b.y
      }
    }
    return ground
  }

  function currentAnim(): { anim: ReturnType<typeof resolveAnim>; name: AnimName } {
    const name = runtime.anim
    return { anim: resolveAnim(stage(), name), name }
  }

  // Icon investigate: drop the mascot on a desktop icon and it reacts —
  // see onPointerUp's drag-release branch, which checks this against the
  // release position and, if it hits, shows the line below and sets
  // runtime.investigateOnLand so the next landing pecks instead of idling.
  const ICON_INVESTIGATE_LINES: Record<string, string> = {
    tasks: '📋 Tasks!',
    kb: '🗂️ Ooh, data!',
    ops: '🛡️ All clear?',
    signals: '📡 Anything new?',
    knowledge: '🔍 Curious…',
    learning: '📈 Growing!',
    settings: '⚙️ Tinkering?'
  }
  const ICON_INVESTIGATE_FALLBACK = '👀 Ooh!'

  /** Which desktop-icon app (if any) the given surface coords land on, using the same grid DesktopIcon.svelte renders with. Returns the app id (the key the investigate-bubble map is indexed by) — iterating the icon-positions store rather than the APPS registry avoids a static import cycle (apps.ts -> MascotLayer -> here -> apps.ts), see docked visibility store wiring. */
  function iconAt(x: number, y: number): string | null {
    const positions = getIconPositions()
    for (const id of Object.keys(positions)) {
      const pos = positions[id] ?? { col: 0, row: 0 }
      const { x: ix, y: iy } = iconPixelPos(pos)
      if (x >= ix && x <= ix + GRID.cell && y >= iy && y <= iy + GRID.cell) return id
    }
    return null
  }

  function tick(now: number): void {
    const dt = Math.min(DT_CLAMP_MS, now - lastFrame)
    lastFrame = now
    // Refresh the ground line at the mascot's current x — this is what
    // lets the mascot walk on top of windows (the ground rises to a
    // window's top edge when the mascot strolls over it).
    const newGround = computeGroundAt(runtime.x)
    // Chase the ground when grounded (not already falling/dragged):
    //  - if it dropped away by more than GROUND_DROP_FALL_PX (a window
    //    closed, moved out from under the mascot, or it walked off an
    //    edge), hand off to a real `falling` behavior instead of snapping
    //    — this was Finding 1 in the physics audit: the mascot used to
    //    teleport straight to the new ground with zero animation.
    //  - otherwise, step toward the new ground by at most
    //    GROUND_FOLLOW_MAX_STEP per tick. A window being dragged smoothly
    //    moves only a few px/tick, so this still reads as an instant,
    //    solid "ride along"; anything bigger (a window snapping to a new
    //    position, a resize) catches up over a couple of frames instead
    //    of jumping.
    if (
      runtime.behavior !== 'falling' &&
      runtime.behavior !== 'dragged' &&
      runtime.behavior !== 'hop'
    ) {
      if (newGround - prevGroundY > GROUND_DROP_FALL_PX && runtime.y >= prevGroundY - 1) {
        forceBehavior(runtime, 'falling')
      } else if (runtime.y !== newGround) {
        const diff = newGround - runtime.y
        runtime.y +=
          Math.abs(diff) <= GROUND_FOLLOW_MAX_STEP ? diff : Math.sign(diff) * GROUND_FOLLOW_MAX_STEP
      }
    }
    runtime.groundY = newGround
    prevGroundY = newGround
    stepMascot(runtime, model, now, dt)
    lastDtMs = dt
    updateJuice(now, dt)
    // Reaction bubble expiry: the bubble is a real DOM element now (see
    // the template), driven reactively by bubbleText/bubbleUntil — this
    // is the one place that clears it once its time is up.
    if (runtime.bubbleText && now >= runtime.bubbleUntil) {
      runtime.bubbleText = null
      runtime.bubbleUntil = 0
    }
    // Typewriter advance (the reset on line change lives in an effect —
    // see the "Speech-bubble feel" block below).
    if (runtime.bubbleText && typedLen < bubbleLen) {
      const perChar = Math.min(TYPE_CHAR_MS, TYPE_MAX_MS / bubbleLen)
      typedLen = Math.min(bubbleLen, typedLen + dt / perChar)
    }
    // Lifecycle (egg incubation, happiness decay) ticks ~1x/sec, not per frame.
    if (lastLifecycle === 0) lastLifecycle = now
    if (now - lastLifecycle >= LIFECYCLE_TICK_MS) {
      tickLifecycle(now - lastLifecycle)
      lastLifecycle = now
      advanceStageIfReady()
      // If the egg just hatched, swap the runtime behavior out of 'egg'
      // (the egg behavior's next() returns 'egg' forever, so we have to
      // nudge it here). The NameDialog is opened by MascotLayer observing
      // the stage change.
      if (model.stage !== 'egg' && runtime.behavior === 'egg') {
        forceBehavior(runtime, 'idle')
      }
    }
  }

  /**
   * Recompute the render-layer feel (see the "Juice" constants above):
   * the impact squash spring / in-air stretch, the air tilt, the walk
   * bob, and the feather-poof spawn. Runs every tick, after stepMascot,
   * so it always reads the freshest physics state. Pure render layer —
   * it never writes back into the physics.
   */
  function updateJuice(now: number, dt: number): void {
    // Squash-and-stretch. The spring wins near an impact; otherwise an
    // airborne body stretches along its motion.
    const st = (now - runtime.squashAt) / 1000
    if (runtime.squashAt > 0 && st < SPRING_WINDOW_S) {
      const depth = Math.min(MAX_SQUASH_DEPTH, runtime.impactVy / 1100)
      const s = depth * Math.exp(-SPRING_DECAY * st) * Math.cos(st * Math.PI * 2 * SPRING_FREQ_HZ)
      juiceSx = 1 + s * 0.75
      juiceSy = 1 - s
    } else if (runtime.behavior === 'falling' || runtime.behavior === 'hop') {
      const stretch = Math.min(0.15, Math.abs(runtime.vy) / 1600)
      juiceSx = 1 - stretch * 0.6
      juiceSy = 1 + stretch
    } else {
      juiceSx = 1
      juiceSy = 1
    }
    // Air tilt: lean into horizontal motion (falling toss, mid-drag swing,
    // landing skid). Smoothed so it eases in/out instead of snapping.
    let tiltTarget = 0
    if (runtime.behavior === 'falling') {
      tiltTarget = Math.max(-14, Math.min(14, runtime.vx * 0.02))
    } else if (runtime.behavior === 'dragged' && moved && dragSamples.length > 1) {
      tiltTarget = Math.max(-16, Math.min(16, tossVelocity().vx * 0.018))
    } else if (runtime.behavior === 'land') {
      tiltTarget = Math.max(-8, Math.min(8, runtime.vx * 0.012))
    }
    juiceTilt += (tiltTarget - juiceTilt) * Math.min(1, dt / 110)
    // Walk bob: two footsteps per walk-anim loop (4 frames @ 8fps = 500ms).
    juiceBob = runtime.behavior === 'wander' ? -Math.abs(Math.sin((now * Math.PI) / 250)) * 1.5 : 0
    // Feather poof: a new, hard-enough impact spawns a burst at the feet.
    if (runtime.squashAt !== lastSquashAt) {
      lastSquashAt = runtime.squashAt
      if (runtime.impactVy >= POOF_MIN_VY) spawnFeathers(runtime.impactVy)
    }
  }

  /** Burst of tiny feather/dust pixels at the mascot's feet; count scales with impact speed. */
  function spawnFeathers(impact: number): void {
    const n = Math.min(9, 4 + Math.floor(impact / 90))
    const now = performance.now()
    for (let i = 0; i < n; i++) {
      feathers.push({
        x: runtime.x + (Math.random() - 0.5) * 8,
        y: runtime.y - Math.random() * 2,
        vx: (Math.random() - 0.5) * 140,
        vy: -(40 + Math.random() * 120),
        born: now,
        life: 450 + Math.random() * 300,
        size: Math.random() < 0.5 ? 1 : 2,
        tint: FEATHER_TINTS[Math.floor(Math.random() * FEATHER_TINTS.length)]
      })
    }
  }

  function renderSprite(): void {
    if (!canvas || !ctx2d) return
    const now = performance.now()
    // Egg wobble: only when stage is egg and behavior isn't dragged/react.
    const isEgg = stage() === 'egg'
    if (isEgg && runtime.behavior !== 'dragged') {
      wigglePhase += 0.08
    } else {
      wigglePhase = 0
    }
    const { anim } = currentAnim()
    const idx = frameIndex(anim, now, runtime.animStart)
    const scale = STAGE_SCALE[stage()]
    drawFrame(ctx2d, stage(), anim, idx, {
      scale,
      facing: runtime.facing,
      wiggle: wigglePhase
    })
    // Feather poof: integrate + paint after the sprite frame (drawFrame
    // clears the canvas each frame). Canvas coords: the sprite's feet are
    // at (CANVAS_W/2, CANVAS_H), so a particle's offset from the mascot is
    // all that's needed — the canvas itself is positioned at the mascot.
    if (feathers.length > 0) {
      const dtS = lastDtMs / 1000
      feathers = feathers.filter((f) => now - f.born < f.life)
      for (const f of feathers) {
        f.vy += FEATHER_GRAVITY * dtS
        f.vx *= Math.max(0, 1 - 2.5 * dtS)
        f.x += f.vx * dtS
        f.y += f.vy * dtS
        ctx2d.globalAlpha = Math.max(0, 1 - (now - f.born) / f.life)
        ctx2d.fillStyle = f.tint
        ctx2d.fillRect(
          Math.round(CANVAS_W / 2 + (f.x - runtime.x)),
          Math.round(CANVAS_H - (runtime.y - f.y)),
          f.size,
          f.size
        )
      }
      ctx2d.globalAlpha = 1
    }
  }

  // Single loop: tick physics + draw sprite + reschedule. (The previous
  // version had `loop` reschedule itself AND call `draw` which also
  // rescheduled itself — two timers fought over the shared `timer` var,
  // causing jitter.)
  function loop(): void {
    timer = setTimeout(loop, LOOP_MS)
    const now = performance.now()
    tick(now)
    renderSprite()
  }

  function onPointerDown(e: PointerEvent) {
    if (e.button !== 0) return
    const el = e.currentTarget as HTMLElement
    dragPointerId = e.pointerId
    dragStartClient = { x: e.clientX, y: e.clientY }
    moved = false
    dragSamples = []
    el.setPointerCapture(e.pointerId)
    forceBehavior(runtime, 'dragged')
    dragging = true
  }

  function onPointerMove(e: PointerEvent) {
    if (dragPointerId !== e.pointerId) return
    const dx = e.clientX - dragStartClient.x
    const dy = e.clientY - dragStartClient.y
    if (!moved && Math.hypot(dx, dy) > DRAG_THRESHOLD) moved = true
    if (moved) {
      // Surface-relative coords: the sprite container is positioned at the
      // surface origin, so clientX/Y - surfaceRect gives surface coords.
      // MascotLayer binds the host's bounding rect; we read it fresh here.
      const host = (e.currentTarget as HTMLElement).parentElement?.parentElement
      const rect = host?.getBoundingClientRect()
      if (rect) {
        runtime.x = Math.max(0, Math.min(rect.width, e.clientX - rect.left))
        runtime.y = Math.max(0, Math.min(rect.height, e.clientY - rect.top))
        const t = performance.now()
        dragSamples.push({ t, x: runtime.x, y: runtime.y })
        while (dragSamples.length > 1 && t - dragSamples[0].t > VELOCITY_WINDOW_MS)
          dragSamples.shift()
      }
    }
  }

  /** Derive a release (toss) velocity from the last VELOCITY_WINDOW_MS of drag samples. */
  function tossVelocity(): { vx: number; vy: number } {
    if (dragSamples.length < 2) return { vx: 0, vy: 0 }
    const first = dragSamples[0]
    const last = dragSamples[dragSamples.length - 1]
    const dt = (last.t - first.t) / 1000
    if (dt < 0.01) return { vx: 0, vy: 0 }
    const vx = Math.max(-TOSS_MAX_VX, Math.min(TOSS_MAX_VX, (last.x - first.x) / dt))
    const rawVy = (last.y - first.y) / dt
    const vy =
      rawVy < 0 ? Math.max(-TOSS_MAX_UPWARD_VY, rawVy) : Math.min(TOSS_MAX_DOWNWARD_VY, rawVy)
    return { vx, vy }
  }

  /**
   * Release capture and clear drag-tracking state. Split out from
   * onPointerUp so onPointerCancel and the window-level fallback below can
   * share it — every path that ends a drag needs to do this exact
   * cleanup, or the mascot is left stuck in `dragged` forever.
   */
  function endDragTracking(pointerId: number, el?: HTMLElement | null): void {
    if (el?.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
    dragPointerId = null
    dragging = false
  }

  function onPointerUp(e: PointerEvent) {
    if (dragPointerId !== e.pointerId) return
    if (!moved) {
      // An unnamed egg (e.g. the naming dialog was Escaped away) has no
      // real pet reaction yet — reopen the naming prompt instead so it's
      // never stuck un-hatchable without a reload/debug action.
      if (stage() === 'egg' && model.name === null) {
        dragSamples = []
        endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
        onRequestName()
        return
      }
      // Plain click = pet: a brief happy reaction with a heart bubble.
      modelPet()
      onPet()
      runtime.bubbleText = '❤️'
      runtime.bubbleUntil = performance.now() + 1500
      forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
    } else {
      // Drag ended — derive a toss velocity from the recent pointer
      // motion and release into falling (or land, if already grounded).
      setLastPos(runtime.x)
      const { vx, vy } = tossVelocity()
      runtime.vx = vx
      runtime.vy = vy
      // Dropped on a desktop icon? Show an "investigate" bubble right
      // away (works whether it's about to fall or is already grounded)
      // and flag the next landing to peck instead of idle — see the
      // `land` BehaviorDef in behavior.ts.
      if (stage() !== 'egg') {
        const app = iconAt(runtime.x, runtime.y)
        if (app) {
          runtime.bubbleText = ICON_INVESTIGATE_LINES[app.id] ?? ICON_INVESTIGATE_FALLBACK
          runtime.bubbleUntil = performance.now() + 1800
          runtime.investigateOnLand = true
          modelPet()
        }
      }
      releaseFromDrag(runtime)
    }
    dragSamples = []
    endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
  }

  /**
   * The browser aborts a pointer interaction (fires `pointercancel`
   * instead of `pointerup`) in several real situations: the pointer
   * leaves the window fast enough that the OS/browser interprets it as a
   * different gesture, a touch/stylus is force-cancelled, or something
   * else takes over pointer capture. Without handling this, the mascot
   * gets stuck in the `dragged` behavior forever — no more pointerup is
   * coming, so nothing else would ever reset dragPointerId/dragging. This
   * was the "dragging sometimes doesn't release" bug. There's no reliable
   * release gesture to derive a toss from here, so it just falls/lands
   * from wherever it was, same as a still-mid-air drag release with no
   * velocity.
   */
  function onPointerCancel(e: PointerEvent) {
    if (dragPointerId !== e.pointerId) return
    dragSamples = []
    runtime.vx = 0
    runtime.vy = 0
    releaseFromDrag(runtime)
    endDragTracking(e.pointerId, e.currentTarget as HTMLElement)
  }

  /**
   * Defense-in-depth fallback: if for any reason the canvas's own
   * pointerup/pointercancel above doesn't fire (pointer capture should
   * guarantee it does, but "should" isn't "always" across browsers/
   * embeddings), this window-level catch-all still ends the drag. Safe to
   * have both — pointer capture redirects the *target* of the event, not
   * its bubbling, so a canvas-handled pointerup also reaches window; by
   * then dragPointerId is already cleared, so this is a no-op in the
   * normal case.
   */
  function onWindowPointerEnd(e: PointerEvent) {
    if (dragPointerId !== e.pointerId) return
    dragSamples = []
    runtime.vx = 0
    runtime.vy = 0
    releaseFromDrag(runtime)
    endDragTracking(e.pointerId, canvas)
  }

  function syncCanvasSize() {
    if (!canvas) return
    canvas.width = CANVAS_W
    canvas.height = CANVAS_H
    ctx2d = canvas.getContext('2d')
    if (ctx2d) ctx2d.imageSmoothingEnabled = false
  }

  onMount(async () => {
    initMascotState()
    syncCanvasSize()
    await loadSprites()
    lastFrame = performance.now()
    lastLifecycle = 0
    // Seed prevGroundY so the first tick's ride-the-ground delta is zero
    // (otherwise the mascot would snap to the ground on mount if it
    // started above it — e.g. an egg at the surface bottom).
    runtime.groundY = computeGroundAt(runtime.x)
    prevGroundY = runtime.groundY
    loop()
    // Re-ground on surface resize (viewport resize, taskbar height changes).
    const host = canvas?.parentElement?.parentElement
    let prevH = runtime.bounds.h
    const ro = new ResizeObserver(() => {
      const rect = host?.getBoundingClientRect()
      if (rect) {
        const newH = rect.height
        runtime.bounds = { w: rect.width, h: newH }
        reground(runtime, prevH)
        prevH = newH
      }
    })
    if (host) ro.observe(host)
    // Track the window manager's state so computeGroundAt() can find the
    // highest window beneath the mascot's x each tick — this is what lets
    // the mascot walk on top of windows rather than always falling to the
    // desktop surface bottom.
    const unsubWm = wmState.subscribe((s) => {
      currentWindows = s
    })
    return () => {
      if (timer) clearTimeout(timer)
      ro.disconnect()
      unsubWm()
    }
  })

  // The sprite's CSS position uses bottom-left anchored coords from
  // MascotRuntime: x = sprite bottom-center, y = sprite bottom. We
  // position via `transform: translate3d` (compositor-friendly, no
  // layout reflow) rather than `left`/`top` so motion stays smooth at
  // high refresh rates. Composed on top of the translation are the juice
  // channels (see updateJuice): the squash-and-stretch impact spring /
  // in-air stretch (scale), the air tilt (rotate), and the walk bob
  // (added to ty). transform-origin is bottom center, so squashes and
  // tilts pivot at the feet.
  //
  // `ty` is clamped to >= 0: the canvas reserves headroom above the
  // sprite for the reaction bubble, and when the mascot's ground is near
  // the top of the viewport (e.g. standing on a freshly-opened window),
  // that headroom would otherwise push the canvas — and the name label
  // above it — partly off-screen. This only affects the *rendered*
  // position; runtime.y (the physics/ground line) is untouched, so it's a
  // display-only fix, not a physics change (see the physics audit's
  // Finding 4).
  const tx = $derived(runtime.x - (CANVAS_W * SCALE_PX) / 2)
  const ty = $derived(Math.max(0, runtime.y - CANVAS_H * SCALE_PX))
  // The canvas (CANVAS_H=28 logical px) is taller than the sprite frame
  // it draws (FRAME_PX=16, bottom-anchored — see render.ts's drawFrame),
  // leaving ~36 screen px of transparent headroom ABOVE the sprite's
  // actual head. `ty` is the canvas's top, not the chicken's — anchoring
  // the name/bubble to `ty` floated them oddly high above the chicken
  // with a big empty gap. `spriteTopY` is where the visible pixels
  // actually start, so the name/bubble can hug the head instead.
  const FRAME_PX = 16
  const spriteTopY = $derived(
    ty + Math.max(0, (CANVAS_H - FRAME_PX * STAGE_SCALE[stage()]) * SCALE_PX)
  )
  const tagCenterX = $derived(tx + (CANVAS_W * SCALE_PX) / 2)
  // Name and reaction text share one floating tag above the head instead
  // of two stacked elements — it shows the reaction (with the speech-
  // bubble-and-tail treatment) when one's active, and falls back to just
  // the name (a plain small pill) the rest of the time. Since only one of
  // the two ever renders, the anchor only needs to account for whichever
  // one is showing, growing upward from a fixed point near the head so it
  // doesn't jump around when it switches between the two.
  const TAG_GAP = 4
  const PILL_HEIGHT = 18
  const BUBBLE_HEIGHT = 40 // card + its downward-pointing tail
  const tagY = $derived(
    Math.max(0, spriteTopY - TAG_GAP - (runtime.bubbleText ? BUBBLE_HEIGHT : PILL_HEIGHT))
  )
  const transform = $derived(
    `translate3d(${tx}px, ${ty + juiceBob}px, 0) rotate(${juiceTilt}deg) scale(${juiceSx}, ${juiceSy})`
  )

  // Bubble text marquee: the bubble is capped at one line (no wrap) and a
  // fixed max width, so text longer than that would normally need
  // ellipsis — but truncating a reaction line silently drops information
  // (e.g. which app it's reacting to). Instead, when the text overflows
  // the bubble's width, it slides left/right like a teleprompter so the
  // whole line eventually becomes readable; short text that already fits
  // just sits still, centered.
  let bubbleTrackEl = $state<HTMLDivElement | null>(null)
  let bubbleTextEl = $state<HTMLSpanElement | null>(null)
  let marqueeDistance = $state(0)
  let marqueeDuration = $state(0)
  const MARQUEE_PX_PER_S = 45 // travel speed while sliding (excludes the hold time at each end)

  // ── Speech-bubble feel ───────────────────────────────────────────────
  // Typewriter reveal (advanced in tick), pop-in spring entrance, a
  // blinking block cursor while typing, and per-reaction tone colors.
  // Pure presentation — the underlying state is still just
  // bubbleText/bubbleUntil.
  const TYPE_CHAR_MS = 28 // per-character typewriter pace…
  const TYPE_MAX_MS = 1400 // …but long lines speed up so typing always fits the display window
  let typedLen = $state(0)

  $effect(() => {
    // Restart the typewriter whenever the line changes. Svelte flushes
    // effects before paint, so a new line never flashes fully-typed for
    // a frame.
    void runtime.bubbleText
    typedLen = 0
  })

  // Code-point count/slicing: emoji are multi-unit UTF-16 ('❤️' is 2),
  // and slicing mid-surrogate renders a broken glyph for a frame.
  const bubbleLen = $derived(runtime.bubbleText ? Array.from(runtime.bubbleText).length : 0)
  const typingDone = $derived(!runtime.bubbleText || typedLen >= bubbleLen)
  const typedText = $derived(
    runtime.bubbleText
      ? Array.from(runtime.bubbleText).slice(0, Math.ceil(typedLen)).join('')
      : null
  )
  // Tiny lines (a lone emoji) get emphasized with a bigger size.
  const isShortBubble = $derived(bubbleLen > 0 && bubbleLen <= 2)

  // Tone: color the bubble by the reaction currently playing (the field
  // is reactAnim — 'react-alarm' → 'alarm'). Only while the react
  // behavior is active, so a stale reactAnim doesn't tint later bubbles
  // (icon-investigate, idle chatter).
  type BubbleTone = 'alarm' | 'eureka' | 'think' | 'happy'
  const bubbleTone = $derived<BubbleTone | null>(
    runtime.behavior === 'react' && runtime.reactAnim?.startsWith('react-')
      ? (runtime.reactAnim.slice(6) as BubbleTone)
      : null
  )
  // card = full treatment (border + text + shadow tint); border = just
  // the border color, for the little tail diamond.
  const TONE: Record<BubbleTone, { card: string; border: string }> = {
    alarm: {
      card: 'border-red-400/70 text-red-500 dark:text-red-300 shadow-red-500/25',
      border: 'border-red-400/70'
    },
    eureka: {
      card: 'border-amber-400/70 text-amber-600 dark:text-amber-300 shadow-amber-500/25',
      border: 'border-amber-400/70'
    },
    think: {
      card: 'border-sky-400/60 text-sky-600 dark:text-sky-300',
      border: 'border-sky-400/60'
    },
    happy: {
      card: 'border-rose-400/70 text-rose-500 dark:text-rose-300 shadow-rose-500/25',
      border: 'border-rose-400/70'
    }
  }

  $effect(() => {
    // Re-measure whenever the visible bubble text changes, and again when
    // the typewriter finishes — while typing, the marquee stays off (the
    // partially-typed line would measure wrong and start sliding
    // mid-reveal).
    void runtime.bubbleText
    void typingDone
    const track = bubbleTrackEl
    const textEl = bubbleTextEl
    if (!track || !textEl || !typingDone) {
      marqueeDistance = 0
      marqueeDuration = 0
      return
    }
    // Measure after layout settles (the span's content just changed).
    const raf = requestAnimationFrame(() => {
      const overflow = textEl.scrollWidth - track.clientWidth
      if (overflow > 2) {
        marqueeDistance = -overflow
        marqueeDuration = (overflow / MARQUEE_PX_PER_S) * 2 + 1.6
        // Callers set bubbleUntil from the reaction/action's own short
        // duration (e.g. 1.5-2.2s), which is usually plenty for a static
        // line but would cut a slow scroll off mid-glide. Stretch the
        // display time to cover one full out-and-back cycle so overflowing
        // text always finishes scrolling into view before the bubble
        // disappears — otherwise the point of scrolling instead of
        // ellipsizing (showing the whole line) would be defeated.
        const minUntil = performance.now() + marqueeDuration * 1000
        if (runtime.bubbleUntil < minUntil) runtime.bubbleUntil = minUntil
      } else {
        marqueeDistance = 0
        marqueeDuration = 0
      }
    })
    return () => cancelAnimationFrame(raf)
  })
</script>

<svelte:window onpointerup={onWindowPointerEnd} onpointercancel={onWindowPointerEnd} />

<canvas
  bind:this={canvas}
  class="pointer-events-auto absolute left-0 top-0 select-none {dragging
    ? 'cursor-grabbing'
    : 'cursor-grab'}"
  style="width: {CANVAS_W * SCALE_PX}px; height: {CANVAS_H *
    SCALE_PX}px; transform: {transform}; image-rendering: pixelated; will-change: transform; transform-origin: bottom center;"
  onpointerdown={onPointerDown}
  onpointermove={onPointerMove}
  onpointerup={onPointerUp}
  onpointercancel={onPointerCancel}
  title={model.name ?? 'Cluck'}
></canvas>

{#if runtime.bubbleText || model.name}
  <div
    class="pointer-events-none absolute left-0 top-0 select-none"
    style="transform: translate3d({tagCenterX}px, {tagY}px, 0) translateX(-50%); will-change: transform;"
  >
    {#if runtime.bubbleText}
      {#key runtime.bubbleText}
        <div
          class="bubble-pop relative max-w-40 rounded-xl border bg-popover px-2.5 py-1 leading-[1.25] shadow-md {bubbleTone
            ? TONE[bubbleTone].card
            : 'text-popover-foreground'} {bubbleTone === 'alarm'
            ? 'bubble-shake'
            : ''} {isShortBubble ? 'text-base' : 'text-sm'}"
        >
          <div
            bind:this={bubbleTrackEl}
            class="overflow-hidden {marqueeDistance === 0 ? 'text-center' : 'text-left'}"
          >
            <span
              bind:this={bubbleTextEl}
              class="inline-block whitespace-nowrap {marqueeDistance !== 0 ? 'bubble-marquee' : ''}"
              style={marqueeDistance !== 0
                ? `--marquee-distance: ${marqueeDistance}px; --marquee-duration: ${marqueeDuration}s;`
                : ''}
              >{typedText}{#if !typingDone}<span class="bubble-cursor"></span>{/if}</span
            >
          </div>
          <div
            class="absolute -bottom-[5px] left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 border-r border-b bg-popover {bubbleTone
              ? TONE[bubbleTone].border
              : ''}"
          ></div>
        </div>
      {/key}
    {:else}
      <div
        class="whitespace-nowrap rounded-full bg-popover/40 px-1.5 py-0.5 text-[10px] font-normal text-popover-foreground/75"
      >
        {model.name}
      </div>
    {/if}
  </div>
{/if}

<style>
  /* Slides overflowing bubble text into view and back, holding briefly at
     each end (like a teleprompter) instead of ellipsizing. Only applied
     when the text is wider than the bubble (see the marqueeDistance
     effect above) — --marquee-distance is the negative px offset needed
     to reveal the clipped tail. */
  .bubble-marquee {
    animation: bubble-marquee var(--marquee-duration, 4s) ease-in-out infinite;
  }
  @keyframes bubble-marquee {
    0%,
    12% {
      transform: translateX(0);
    }
    50%,
    62% {
      transform: translateX(var(--marquee-distance, 0px));
    }
    100% {
      transform: translateX(0);
    }
  }

  /* Pop-in entrance: fast overshoot-and-settle spring, pivoting at the
     tail (bottom center) so the bubble grows out of the mascot's head.
     Replays on every line change via the {#key} block in the template. */
  .bubble-pop {
    animation: bubble-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
    transform-origin: bottom center;
  }
  @keyframes bubble-pop {
    0% {
      transform: scale(0.3);
      opacity: 0;
    }
    55% {
      transform: scale(1.1);
      opacity: 1;
    }
    75% {
      transform: scale(0.97);
    }
    100% {
      transform: scale(1);
    }
  }

  /* Alarm shake: runs right after the pop finishes (the two animations
     stack on the same element — the later shake wins on transform while
     it runs, then the pop's settled scale(1) fill takes back over). */
  .bubble-shake {
    animation:
      bubble-pop 340ms cubic-bezier(0.34, 1.56, 0.64, 1) both,
      bubble-shake 380ms 360ms ease-in-out;
  }
  @keyframes bubble-shake {
    0%,
    100% {
      transform: translateX(0);
    }
    20% {
      transform: translateX(-3px);
    }
    40% {
      transform: translateX(3px);
    }
    60% {
      transform: translateX(-2px);
    }
    80% {
      transform: translateX(2px);
    }
  }

  /* Blinking block cursor shown while the typewriter is mid-line. */
  .bubble-cursor {
    display: inline-block;
    width: 2px;
    height: 1em;
    margin-left: 1px;
    vertical-align: text-bottom;
    background: currentColor;
    animation: bubble-caret 0.75s steps(1) infinite;
  }
  @keyframes bubble-caret {
    50% {
      opacity: 0;
    }
  }
</style>
