<script lang="ts">
  // The desktop mascot sprite: a small canvas that renders the current
  // animation frame at ~30fps (via setTimeout, not rAF — matches
  // GraphBackground.svelte's convention for hidden-tab embedding safety),
  // 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, loadBubble, resolveAnim, frameIndex, STAGE_SCALE } from '$lib/mascot/sprites'
  import { drawFrame, drawBubble, 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'

  // MascotRuntime is created fresh per mount; the long-lived MascotModel
  // (with lastPos, stage, name, ...) persists across mounts via localStorage.
  let { runtime, onContextMenu, onPet }: { runtime: MascotRuntime; onContextMenu: (screenX: number, screenY: number) => void; onPet: () => void } = $props()

  const SCALE_PX = 3 // CSS scale: 20 logical px * 3 = 60px sprite
  const DRAG_THRESHOLD = 5
  // 60fps for the sprite loop: the mascot has faster motion (drag, fall)
  // than GraphBackground's slow ambient drift, and 30fps position updates
  // look choppy on 60Hz+ displays. setTimeout (not rAF) per the repo
  // convention — 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

  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
  // Wiggle phase for the egg (render-time only, not persisted).
  let wigglePhase = 0
  // 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
   * highest non-minimized window whose horizontal span covers the
   * mascot's x, or the surface bottom (bounds.h) when no window is
   * beneath. This is what lets the mascot walk ON TOP of windows —
   * when it strolls over a window, the ground rises to that window's
   * top edge; when it walks off the side, the ground drops back to the
   * desktop surface and it flutters-falls.
   */
  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).
      if (x >= b.x - 4 && x <= b.x + b.width + 4) {
        // Walking on top = ground at the window's top edge.
        if (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 }
  }

  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)
    // Ride the ground: when the mascot is grounded (not falling/dragged)
    // and the ground moved (the window beneath was dragged/resized),
    // translate the mascot with it so it sticks to the surface instead
    // of floating in place while the window drifts out from under it.
    if (
      runtime.behavior !== 'falling' &&
      runtime.behavior !== 'dragged' &&
      runtime.y >= prevGroundY - 1 &&
      newGround !== prevGroundY
    ) {
      runtime.y += newGround - prevGroundY
    }
    runtime.groundY = newGround
    prevGroundY = newGround
    stepMascot(runtime, model, now, dt)
    // 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')
      }
    }
  }

  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
    })
    // Reaction bubble: drawn above the sprite while runtime.bubble is set
    // and hasn't expired. Cleared when bubbleUntil passes.
    if (runtime.bubble && now < runtime.bubbleUntil) {
      drawBubble(ctx2d, runtime.bubble, runtime.facing)
    } else if (runtime.bubble && now >= runtime.bubbleUntil) {
      runtime.bubble = null
      runtime.bubbleUntil = 0
    }
  }

  // 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
    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))
      }
    }
  }

  function onPointerUp(e: PointerEvent) {
    if (dragPointerId !== e.pointerId) return
    const el = e.currentTarget as HTMLElement
    el.releasePointerCapture(e.pointerId)
    dragPointerId = null
    dragging = false
    if (!moved) {
      // Plain click = pet: a brief happy reaction with a heart bubble.
      modelPet()
      onPet()
      runtime.bubble = '/mascot/bubble-love.png'
      runtime.bubbleUntil = performance.now() + 1500
      forceBehavior(runtime, 'react', { anim: 'react-happy', durationMs: 1500 })
    } else {
      // Drag ended — release into falling or land.
      setLastPos(runtime.x)
      releaseFromDrag(runtime)
    }
  }

  function handleContextMenu(e: MouseEvent) {
    e.preventDefault()
    e.stopPropagation()
    onContextMenu(e.clientX, e.clientY)
  }

  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()
    // Preload reaction bubbles (not in SPRITES registry, so loadSprites
    // doesn't pick them up). Swallow errors — a missing bubble just
    // doesn't render, the reaction anim still plays.
    await Promise.allSettled([
      loadBubble('/mascot/bubble-love.png'),
      loadBubble('/mascot/bubble-exclaim.png'),
      loadBubble('/mascot/bubble-red-exclaim.png'),
      loadBubble('/mascot/bubble-dotdotdot.png')
    ])
    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. A squash transform is composed in during the
  // `land` behavior (brief scaleY(0.82)) to sell the impact.
  const tx = $derived(runtime.x - (CANVAS_W * SCALE_PX) / 2)
  const ty = $derived(runtime.y - CANVAS_H * SCALE_PX)
  const squash = $derived(runtime.behavior === 'land' ? ' scaleY(0.82)' : '')
  const transform = $derived(`translate3d(${tx}px, ${ty}px, 0)${squash}`)
</script>

<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}
  oncontextmenu={handleContextMenu}
  title={model.name ?? 'Cluck'}
></canvas>

{#if model.name}
  <div
    class="pointer-events-none absolute left-0 top-0 select-none whitespace-nowrap rounded-full bg-popover/90 px-2 py-0.5 text-[11px] font-medium text-popover-foreground shadow-sm ring-1 ring-foreground/10"
    style="transform: translate3d({tx + (CANVAS_W * SCALE_PX) / 2}px, {ty - 8}px, 0); will-change: transform;"
  >
    {model.name}
  </div>
{/if}
