"""Wivexx minimalist ball overlay — a genuinely transparent, glossy floating orb.

PER-PIXEL ALPHA (the Bongo-Cat / desktop-pet technique): the orb is drawn as an
RGBA bitmap and presented through a Win32 LAYERED window via UpdateLayeredWindow.
That gives true per-pixel transparency — smooth anti-aliased edges, a soft volt
glow and a soft contact shadow that blend correctly over the real desktop, and
free click-through on the empty areas — none of which the old color-key approach
could do (color-key is 1-bit: a pixel is either fully on the key colour or fully
opaque, so soft glows became un-keyable smudges).

Why a layered bitmap and not the WebGL dot: a windowed WebView2 surface composites
through DirectComposition and can't be made transparent (verified). Bitmap content
presented via UpdateLayeredWindow can — exactly how Bongo Cat / Rainmeter do it.

The WINDOW + INPUT (drag, click-to-restore, right-click menu, show/hide) stay a tk
Toplevel on the sidecar root; only the PAINT is swapped from a color-key Canvas to
UpdateLayeredWindow. Renders the same glossy faux-3D 'candy toy' look as ui/dot.js:
a volt top→bottom gradient, a pinned plastic sheen, a moving shine that tracks the
cursor (gaze), blink, soft glow + shadow, squash/stretch, and the listening/
speaking ripple rings + processing satellite.
"""
from __future__ import annotations

import math
import sys
import time
from typing import Callable, Optional

import numpy as np
from PIL import Image

# ── glossy brand palette (0..1), matches dot.js ───────────────────────────────
VOLT    = np.array([0.757, 0.969, 0.227], np.float32)
VOLT_HI = np.array([0.86, 1.0, 0.42], np.float32)
CBOT    = np.array([0.16, 0.28, 0.05], np.float32)
INK     = np.array([0.043, 0.043, 0.039], np.float32)
BOUNCE  = np.array([0.78, 0.98, 0.36], np.float32)
WHITE   = np.array([1.0, 1.0, 1.0], np.float32)
VOLT255 = (193, 247, 58)
# mascot outfit colors (match the control-room dot's accessories)
AMBER255    = (245, 166, 35)
AMBER_DK255 = (200, 127, 18)
INK255      = (21, 21, 15)
STONE255    = (138, 138, 133)
BONE255     = (242, 240, 234)
CAP255      = (35, 35, 31)

# website candy palette (matches wivexx.com's WivexxOrb radial gradient stops)
CREAM = np.array([0.957, 1.000, 0.824], np.float32)   # #f4ffd2
LIME  = np.array([0.863, 0.984, 0.431], np.float32)   # #dcfb6e
GREEN = np.array([0.541, 0.808, 0.118], np.float32)   # #8ace1e
OLIVE = np.array([0.353, 0.549, 0.078], np.float32)   # #5a8c14 (the soft candy edge)

# "thinking" stays GREEN (the site's mascot is green in every state) — a faint teal
# shimmer + teal bubble/scanlines signal processing without the old blue hologram
TEAL     = np.array([0.55, 0.95, 0.72], np.float32)
TEAL255  = (150, 240, 205)
# (legacy blue hologram palette kept for compatibility; no longer used for the body)
HOLO     = np.array([0.26, 0.60, 1.00], np.float32)
HOLO_HI  = np.array([0.58, 0.86, 1.00], np.float32)
HOLO_BOT = np.array([0.05, 0.14, 0.34], np.float32)
HOLO_BNC = np.array([0.45, 0.76, 1.00], np.float32)
HOLO255  = (92, 168, 255)

_SS = 2
_BALL_R = 0.25   # ball radius as a fraction of the canvas. Smaller than the visible
                 # ball needs, on purpose: it leaves a transparent margin so the
                 # speaking rings can fully expand + fade instead of clipping at the
                 # window edge. The window is sized up to keep the ball's on-screen px.
_STATES = ("idle", "listening", "processing", "speaking")

# ── eye expressions ───────────────────────────────────────────────────────────
# The white optic is a SINGLE eye (Portal personality-core / turret style — one
# lens, not two), and it emotes by reshaping: (w, h, lid, curve, bright) where
#   w, h  = horizontal / vertical scale of the eye        (round=1,1)
#   lid   = top-eyelid closure 0..1 (droops from the top, for sleepy / suspicious)
#   curve = morph 0..1 into a happy ‿ crescent (closed smiling eye)
#   bright= alpha multiplier
# Params are eased toward the target each frame, so expressions morph elegantly.
_EXPR = {
"neutral":    (1.00, 1.00, 0.00, 0.00, 1.00),   # the calm round optic
"alert":      (1.12, 1.20, 0.00, 0.00, 1.12),   # listening — wide & attentive
"focused":    (1.06, 0.50, 0.06, 0.00, 1.06),   # processing — narrowed slit
"happy":      (1.14, 0.96, 0.00, 1.00, 1.14),   # reaction — ‿ smiling crescent
"surprised":  (1.34, 1.40, 0.00, 0.00, 1.24),   # reaction — big startled pop
"sleepy":     (1.00, 0.82, 0.60, 0.00, 0.80),   # idle vignette — heavy eyelid
"suspicious": (1.08, 0.44, 0.34, 0.00, 0.96),   # reaction — low narrowed glare
"squint":     (1.12, 0.30, 0.30, 0.00, 1.00),   # recording — peering through a viewfinder
}
_STATE_EXPR = {"idle": "neutral", "listening": "alert",
"processing": "focused", "speaking": "neutral"}

# ── Win32 structs for the layered window (defined once; ctypes is Windows-only
#    at call time but these defs import fine anywhere) ──────────────────────────
import ctypes as _ct
from ctypes import wintypes as _wt


class _POINT(_ct.Structure):
    _fields_ = [("x", _wt.LONG), ("y", _wt.LONG)]


class _SIZE(_ct.Structure):
    _fields_ = [("cx", _wt.LONG), ("cy", _wt.LONG)]


class _BLEND(_ct.Structure):
    _fields_ = [("BlendOp", _ct.c_byte), ("BlendFlags", _ct.c_byte),
                ("SourceConstantAlpha", _ct.c_byte), ("AlphaFormat", _ct.c_byte)]


class _BMIH(_ct.Structure):
    _fields_ = [("biSize", _wt.DWORD), ("biWidth", _wt.LONG), ("biHeight", _wt.LONG),
                ("biPlanes", _wt.WORD), ("biBitCount", _wt.WORD), ("biCompression", _wt.DWORD),
                ("biSizeImage", _wt.DWORD), ("biXPelsPerMeter", _wt.LONG),
                ("biYPelsPerMeter", _wt.LONG), ("biClrUsed", _wt.DWORD),
                ("biClrImportant", _wt.DWORD)]


def _ss(a: float, b: float, x: np.ndarray) -> np.ndarray:
    t = np.clip((x - a) / (b - a), 0.0, 1.0)
    return t * t * (3.0 - 2.0 * t)


def _render_base(S: int, tint=None, tint_amt: float = 0.0) -> Image.Image:
    """The glossy candy ball as a straight-alpha RGBA sprite (cached once) — matches
    wivexx.com's WivexxOrb: a RADIAL gloss gradient centred on the upper-left
    highlight (cream→lime→volt→green→olive), a soft belly shade, a plastic top-left
    lift + sheen, a SOFT OLIVE edge (NO black rim), and an anti-aliased silhouette.
    `tint`/`tint_amt` nudge the finished body toward a colour — a faint teal for the
    green 'thinking' twin, so the mascot stays green in every state like the site."""
    c = (S - 1) * 0.5
    yy, xx = np.mgrid[0:S, 0:S].astype(np.float32)
    qx, qy = xx - c, c - yy
    d = np.sqrt(qx * qx + qy * qy)
    R = S * _BALL_R
    ux, uy, rr = qx / R, qy / R, d / R

    # bright, EVEN candy body (matches wivexx.com's WivexxOrb): mostly volt, a
    # cream/lime lift toward the pinned upper-left highlight, and a GENTLE green
    # darkening ONLY at the rim (radial → symmetric, so no lopsided 'dirty' side).
    # No heavy belly shadow — the site orb reads clean/even.
    hlD = np.sqrt((ux + 0.30) ** 2 + (uy - 0.42) ** 2)
    body = np.broadcast_to(VOLT, (S, S, 3)).astype(np.float32).copy()
    l1 = _ss(0.62, 0.16, hlD)[..., None]; body = body * (1 - l1) + LIME * l1     # lime lift
    l2 = _ss(0.30, 0.00, hlD)[..., None]; body = body * (1 - l2) + CREAM * l2    # cream hotspot
    g1 = (_ss(0.60, 1.00, rr) * 0.75)[..., None]; body = body * (1 - g1) + GREEN * g1  # gentle rim
    g2 = (_ss(0.92, 1.00, rr) * 0.55)[..., None]; body = body * (1 - g2) + OLIVE * g2  # thin edge
    body = body * (1.0 - 0.05 * _ss(0.10, -1.0, uy)[..., None])                  # whisper of roundness
    glint = _ss(0.20, 0.0, np.sqrt((ux + 0.30) ** 2 + ((uy - 0.44) * 1.06) ** 2))[..., None] * 0.5
    body = body * (1 - glint) + WHITE * glint                                    # clean specular glint
    ed = (_ss(0.93, 1.0, rr) * 0.35)[..., None]                                  # thin clean edge
    body = body * (1 - ed) + (GREEN * 0.88) * ed
    if tint is not None and tint_amt > 0.0:                                      # green→teal 'thinking'
        body = body * (1 - tint_amt) + np.asarray(tint, np.float32) * tint_amt

    body = np.clip(body, 0.0, 1.0)
    alpha = _ss(R, R - max(1.5, S * 0.012), d)                                  # AA silhouette
    rgba = np.dstack([body, alpha[..., None]])
    return Image.fromarray((np.clip(rgba, 0.0, 1.0) * 255).astype(np.uint8), "RGBA")


def _soft_disc(S: int, color, inner: float, outer: float) -> Image.Image:
    """A precomputed soft radial sprite (color with a smooth alpha falloff),
    used for the glow aura and the moving shine — pasted at varying opacity."""
    c = (S - 1) * 0.5
    yy, xx = np.mgrid[0:S, 0:S].astype(np.float32)
    r = np.sqrt((xx - c) ** 2 + (yy - c) ** 2) / (S * 0.5)
    a = _ss(outer, inner, r)
    col = np.array(color, np.float32) / 255.0
    rgba = np.dstack([np.broadcast_to(col, (S, S, 3)), a[..., None]])
    return Image.fromarray((np.clip(rgba, 0.0, 1.0) * 255).astype(np.uint8), "RGBA")


def _frac(x: float) -> float:
    return x - math.floor(x)


def _hash1(x: float) -> float:
    """fract(sin(x) * 43758.5453) — the canonical GLSL hash, mirrored for parity."""
    s = math.sin(x) * 43758.5453
    return s - math.floor(s)


def _hshift(block: np.ndarray, s: int) -> np.ndarray:
    """Shift a block horizontally by `s` px, zero-filling (transparent) the gap —
    a non-wrapping roll so tear fragments don't wrap to the far side."""
    if s == 0:
        return block
    r = np.zeros_like(block)
    if s > 0:
        r[:, s:] = block[:, :-s]
    else:
        r[:, :s] = block[:, -s:]
    return r


def _apply_glitch(arr: np.ndarray, glitch: float, t: float, R: float) -> np.ndarray:
    """Digital-blip post-process mirroring dot.js: random horizontal band tear,
    RGB chromatic split, and moving dark scanlines. In/out: (H, W, 4) uint8 RGBA."""
    if glitch <= 0.001:
        return arr
    H, W = arr.shape[:2]
    out = arr.copy()

    # 1) tear ~random horizontal bands sideways (re-randomized ~20×/s → flicker)
    band_h = max(1, int(R * 0.14))
    tphase = math.floor(t * 20.0)
    for b0 in range(0, H, band_h):
        gh = _hash1((b0 // band_h) * 91.17 + tphase * 13.7)
        if gh > 0.65:
            shift = int((_frac(gh * 53.0) - 0.5) * R * 0.6 * glitch)
            out[b0:b0 + band_h] = _hshift(out[b0:b0 + band_h], shift)

    # 2) chromatic split — red shifts left, blue shifts right, alpha widened so
    #    the coloured fringes read just outside the silhouette
    gx = max(1, int(glitch * R * 0.05))
    a = out[:, :, 3]
    out[:, :, 0] = np.roll(out[:, :, 0], -gx, axis=1)
    out[:, :, 2] = np.roll(out[:, :, 2], gx, axis=1)
    out[:, :, 3] = np.maximum.reduce([a, np.roll(a, -gx, axis=1), np.roll(a, gx, axis=1)])

    # 3) moving dark scanlines
    py = np.arange(H, dtype=np.float32) - H * 0.5
    sl = (np.mod(py * 0.22 + t * 25.0, 1.0) >= 0.5).astype(np.float32)
    fac = (1.0 - 0.20 * glitch * sl)[:, None, None]
    out[:, :, :3] = np.clip(out[:, :, :3].astype(np.float32) * fac, 0, 255).astype(np.uint8)
    return out


class _BallRenderer:
    """Produces each animated RGBA frame: a cached glossy sprite squash-scaled +
    bob-translated, with a soft glow + contact shadow behind it, a moving shine on
    top, and the per-state ring/satellite — all alpha-composited so the result is
    a clean transparent PNG ready for UpdateLayeredWindow."""

    def __init__(self, size: int, ss: Optional[int] = None) -> None:
        self.size = int(size)
        self.ss = max(1, int(_SS if ss is None else ss))
        self.S = self.size * self.ss
        self._base: Optional[Image.Image] = None
        self._base_holo: Optional[Image.Image] = None
        self._glow: Optional[Image.Image] = None
        self._holo_aura: Optional[Image.Image] = None

    def _sprites(self):
        if self._base is None:
            self._base = _render_base(self.S)
            self._base_holo = _render_base(self.S, tint=TEAL, tint_amt=0.12)   # green, faint teal
            self._glow = _soft_disc(self.S, VOLT255, 0.20, 0.92)
            self._holo_aura = _soft_disc(self.S, TEAL255, 0.10, 0.99)          # teal-green, not blue
        return self._base, self._glow

    def _eye_sprite(self, w: float, h: float, lid: float, curve: float,
                    bright: float, blink: float) -> Image.Image:
        """Build the white optic as a soft shape that emotes: width/height scale,
        a top eyelid (droop), a happy ‿ crescent morph, brightness, and a blink
        that squashes it shut. Soft-edged to match the glossy shine it replaces."""
        E = max(12, int(self.S * 0.36))
        c = (E - 1) * 0.5
        yy, xx = np.mgrid[0:E, 0:E].astype(np.float32)
        ux = (xx - c) / (E * 0.5)                          # -1..1
        uy = (c - yy) / (E * 0.5)                          # -1..1 (up)
        base_r = 0.245                                     # optic size (matches the site's eye)
        rw = base_r * max(0.12, w)
        rh = base_r * max(0.04, h * (1.0 - blink))         # blink squashes height
        d = np.sqrt((ux / rw) ** 2 + (uy / rh) ** 2)
        a = _ss(1.06, 0.55, d)                             # soft eye disc
        if lid > 0.001:                                    # top eyelid droops down
            lid_y = rh * (1.0 - 2.0 * lid)
            a = a * _ss(lid_y + rh * 0.20, lid_y - rh * 0.02, uy)
        if curve > 0.001:                                  # happy ‿ crescent
            shift = rh * 0.95
            d2 = np.sqrt((ux / rw) ** 2 + ((uy - shift) / rh) ** 2)
            cres = np.clip(a - _ss(1.06, 0.55, d2), 0.0, 1.0)
            a = a * (1.0 - curve) + cres * curve
        a = np.clip(a * bright, 0.0, 1.0)
        rgba = np.dstack([np.ones((E, E, 3), np.float32), a[..., None]])
        return Image.fromarray((rgba * 255).astype(np.uint8), "RGBA")

    def frame(self, state: str, t: float, squash: float, bob: float,
              look=(0.0, 0.0), level: float = 0.0, blink: float = 0.0,
              energy: float = 0.5, glitch: float = 0.0,
              eye=(1.0, 1.0, 0.0, 0.0, 1.0), holo: float = 0.0,
              work: bool = False, low: bool = False, music: bool = False,
              gaming: bool = False, video: bool = False, coding: bool = False,
              call: bool = False, design: bool = False, shopping: bool = False,
              gadget: str = "", body_dx: float = 0.0, body_dy: float = 0.0) -> Image.Image:
        S = self.S
        R = S * _BALL_R
        base, glow = self._sprites()
        syf = max(0.2, 1.0 + squash)
        sxf = 1.0 / syf
        grow = 1.0 + (level * 0.05 if state == "speaking" else 0.0)
        nw = max(1, int(round(S * sxf * grow)))
        nh = max(1, int(round(S * syf * grow)))
        sprite = base if (nw == S and nh == S) else base.resize((nw, nh), Image.BILINEAR)

        # body offset — the whole orb slides for a gadget pose (the recording
        # left↔right "filming" pan, the clicker shake). Normalised -1..1 of R.
        bdx = int(round(body_dx * R))
        bdy = int(round(body_dy * R))
        bob_px = int(round(bob * self.size * self.ss))
        cx, cy = S // 2 + bdx, S // 2 - bob_px + bdy
        canvas = Image.new("RGBA", (S, S), (0, 0, 0, 0))

        # soft glow aura — volt normally; it fades out and a blue HOLOGRAM bubble
        # fades in while thinking ("gets blue with a blue ball around him")
        gop = 0.24 + 0.14 * energy + (0.22 * level if state == "speaking" else 0.0)
        canvas.alpha_composite(_scale_alpha(glow, gop * (1.0 - holo)), (bdx, -bob_px + bdy))
        if holo > 0.01:
            canvas.alpha_composite(_scale_alpha(self._holo_aura, 0.50 * holo), (bdx, -bob_px + bdy))

        # the ball (volt), then a blue overlay that tints it to hologram by `holo`
        ball_xy = ((S - nw) // 2 + bdx, (S - nh) // 2 - bob_px + bdy)
        canvas.alpha_composite(sprite, ball_xy)
        if holo > 0.01:
            hs = (self._base_holo if (nw == S and nh == S)
                  else self._base_holo.resize((nw, nh), Image.BILINEAR))
            canvas.alpha_composite(_scale_alpha(hs, holo), ball_xy)

        # the optic / "eye" — a soft white shape that EMOTES (shaped by `eye`),
        # slides toward the gaze, and blinks shut by squashing
        shx = look[0] * 1.7                        # symmetric: reaches the clamp both ways
        shy = (0.06 + look[1] * 2.05)
        mag = math.hypot(shx, shy)
        if mag > 0.66:
            shx, shy = shx * 0.66 / mag, shy * 0.66 / mag
        w, h, lid, curve, bright = eye
        eye_img = self._eye_sprite(w, h, lid, curve, bright, blink)
        ew = eye_img.size[0]
        px = int(cx + shx * R - ew / 2)
        py = int(cy - shy * R - ew / 2)
        canvas.alpha_composite(eye_img, (px, py))

        if holo > 0.01:                            # hologram bubble shell + scanlines
            self._hologram(canvas, holo, t, cx, cy)
        if state in ("listening", "speaking"):
            self._rings(canvas, state, t, cx, cy)

        # mascot items (tie / headphones / controller / popcorn / glasses / headset /
        # brush / bag) + gadget poses (camera / clicker / keyboard) are drawn from the
        # SHARED spec (ui/mascot_items.js) via wivexx_mascot_items, so this floating orb
        # and the control room render the SAME designs from ONE file — edit once.
        self._draw_items(canvas, cx, cy, R, t, look,
                         work, low, music, gaming, video, coding, call, design, shopping, gadget)

        # rare digital glitch blip — same effect as the control-room dot
        if glitch > 0.001:
            a = np.array(canvas, dtype=np.uint8)
            a = _apply_glitch(a, glitch, t, S * _BALL_R)
            canvas = Image.fromarray(a, "RGBA")

        if self.ss != 1:
            canvas = canvas.resize((self.size, self.size), Image.BILINEAR)
        return canvas

    def _draw_items(self, canvas, cx, cy, R, t, look,
                    work, low, music, gaming, video, coding, call, design, shopping, gadget):
        """Draw the active accessories / gadget from the SHARED spec (ui/mascot_items.js
        via wivexx_mascot_items) — ONE design source for this orb AND the control room.
        Z-order: worn/held items first, the sleepy cap + zzz on top."""
        try:
            import wivexx_mascot_items as _mi
        except Exception:
            return
        names = []
        if gadget:
            g = {"recording": "camera", "clicker": "clicker", "playback": "keys"}.get(gadget)
            if g:
                names.append(g)
        else:
            if work:
                names.append("tie")
            if music:
                names.append("hp")
            if gaming:
                names.append("controller")
            if video:
                names.append("popcorn")
            if coding:
                names.append("glasses")
            if call:
                names.append("headset")
            if design:
                names.append("brush")
            if shopping:
                names.append("bag")
            activity = music or gaming or video or coding or call or design or shopping
            if low and not activity:
                names.append("cap")
                names.append("zzz")
        for nm in names:
            try:
                _mi.draw_item(canvas, nm, cx, cy, R, t, look)
            except Exception:
                pass

    def _rings(self, canvas, state, t, cx, cy) -> None:
        from PIL import ImageDraw
        ov = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
        dr = ImageDraw.Draw(ov)
        R = self.S * _BALL_R
        speed = 0.9 if state == "speaking" else 0.6
        w = max(1, int(round(2.0 * self.ss)))
        for i in range(3):
            ph = math.fmod(t * speed + i * 0.7, 2.1)
            fade = max(0.0, 1.0 - ph / 2.1) * 0.6
            if fade <= 0.03:
                continue
            rr = R * (1.04 + ph * 0.45)
            dr.ellipse([cx - rr, cy - rr, cx + rr, cy + rr],
                       outline=(VOLT255[0], VOLT255[1], VOLT255[2], int(255 * fade)), width=w)
        canvas.alpha_composite(ov)

    # (the hand-coded _outfits/_gadget_draw were removed — items are drawn from the
    #  SHARED spec ui/mascot_items.js via wivexx_mascot_items in _draw_items above.)
    def _hologram(self, canvas, holo, t, cx, cy) -> None:
        """Thinking cue: concentric TEAL-GREEN bubble-shell rings around the orb plus
        faint drifting scanlines over it (clipped to the ball circle). Green, not
        blue — the mascot stays green in every state, matching wivexx.com."""
        from PIL import ImageDraw
        ov = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
        dr = ImageDraw.Draw(ov)
        R = self.S * _BALL_R
        b = TEAL255
        lw = max(1, int(round(self.ss * 1.2)))
        for i, rad in enumerate((1.10, 1.22)):                 # the bubble shell
            rr = R * rad
            pulse = 0.85 + 0.15 * math.sin(t * 2.4 - i)
            a = int(150 * holo * (1.0 - i * 0.45) * pulse)
            dr.ellipse([cx - rr, cy - rr, cx + rr, cy + rr],
                       outline=(b[0], b[1], b[2], a), width=lw)
        step = max(2, int(self.ss * 4))                        # drifting scanlines (faint)
        off = int((t * 26) % step)
        sa = int(22 * holo)
        for y in range(int(cy - R), int(cy + R) + 1, step):
            yy = y + off
            dyc = yy - cy
            if abs(dyc) >= R:
                continue
            hw = math.sqrt(max(0.0, R * R - dyc * dyc))
            dr.line([(cx - hw, yy), (cx + hw, yy)], fill=(b[0], b[1], b[2], sa), width=1)
        canvas.alpha_composite(ov)

    def _satellite(self, canvas, t, cx, cy) -> None:
        from PIL import ImageDraw
        ov = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
        dr = ImageDraw.Draw(ov)
        R = self.S * _BALL_R
        a = t * 1.9
        sx = cx + math.cos(a) * R * 1.4
        sy = cy + math.sin(a) * R * 0.55
        sr = R * 0.16
        dim = 0.45 if math.sin(a) < 0.0 else 1.0
        dr.ellipse([sx - sr, sy - sr, sx + sr, sy + sr],
                   fill=(VOLT255[0], VOLT255[1], VOLT255[2], int(255 * dim)),
                   outline=(11, 11, 10, int(255 * dim)), width=max(1, int(self.ss * 1.5)))
        canvas.alpha_composite(ov)


def _scale_alpha(img: Image.Image, factor: float) -> Image.Image:
    """Return a copy of an RGBA image with its alpha channel scaled by `factor`."""
    factor = max(0.0, min(1.0, factor))
    r, g, b, a = img.split()
    a = a.point(lambda v: int(v * factor))
    return Image.merge("RGBA", (r, g, b, a))


# ─────────────────────────────────────────────────────────────────────────────
#  physics — mirrors dot.js (squash/stretch, float, gaze, blink, smooth speaking)
# ─────────────────────────────────────────────────────────────────────────────
class _Physics:
    def __init__(self) -> None:
        self.sq = self.sqV = self.by = self.byV = 0.0
        self.lookX = self.lookY = 0.0
        self.level = self.levelTarget = self.spk = 0.0
        self.energy = 0.18
        self.state = "idle"
        self.blink = 0.0
        self._blinkAge = 0.0
        self._blinkCd = 2.0
        self._blinking = False
        self.glitch = 0.0
        self._glitchAge = 0.0
        self._glitchCd = 2.0
        self._glitching = False
        # expression: current (eased) eye params, the state-driven base, and a
        # transient reaction that overrides the base for a short hold
        self.eye = list(_EXPR["neutral"])
        self._exprBase = "neutral"
        self._react = None
        self._reactT = 0.0
        self._vigCd = 5.0
        self.holo = 0.0                 # 0 = volt, 1 = blue hologram (thinking)
        self.gadget = ""                # app-state pose: recording / clicker / playback
        self._gadgetExpr = None         # eye override while a gadget pose is held
        self.gdx = self.gdy = 0.0       # normalised body offset for the gadget motion
        self._curNear = False           # cursor proximity (for perk-up reactions)
        self._lastLookX = self._lastLookY = 0.0
        self._idleSince = 0.0
        self._dozing = False
        self._ttPrev = 0.0

    def set_state(self, s: str) -> None:
        if s not in _STATES or s == self.state:
            return
        prev = self.state
        self.state = s
        hard = 0.8 if s == "speaking" else 0.6
        self.sqV -= 0.42 * hard
        self.byV += 0.045 * hard
        self._exprBase = _STATE_EXPR.get(s, "neutral")
        self._dozing = False
        if s == "listening" and prev in ("idle", "speaking"):
            self.react("surprised", 0.55)            # a startled "?" perk on wake

    def react(self, name: str, hold: float = 0.7) -> None:
        """Play a transient expression that overrides the state default briefly."""
        if name in _EXPR:
            self._react = name
            self._reactT = max(self._reactT, float(hold))

    def set_gadget(self, name: str) -> None:
        """Hold a sustained app-state pose (recording / clicker / playback) until
        cleared with ''. Recording squints through the viewfinder; playback narrows
        in focus; clicker keeps neutral eyes (its energy is in the body shake). The
        body motion itself is computed each step into (gdx, gdy)."""
        self.gadget = str(name or "")
        self._gadgetExpr = ("squint" if self.gadget == "recording"
                            else "focused" if self.gadget == "playback" else None)
        if not self.gadget:
            self.gdx = self.gdy = 0.0
            self._dozing = False

    def notice_cursor(self, look) -> None:
        """React to the cursor: perk up ('alert') as you approach while idle, and
        startle awake ('surprised') from a doze when you move after sitting still."""
        dx, dy = look
        near = math.hypot(dx, dy) < 0.5
        if near and not self._curNear and self.state == "idle":
            self.react("alert", 0.55)
        self._curNear = near
        mv = math.hypot(dx - self._lastLookX, dy - self._lastLookY)
        self._lastLookX, self._lastLookY = dx, dy
        if mv > 0.05:
            self._idleSince = self._ttPrev
            if self._dozing:
                self._dozing = False
                self.react("surprised", 0.6)

    def set_level(self, v: float) -> None:
        self.levelTarget = max(0.0, min(1.0, float(v)))

    def step(self, tt: float, look_target=None):
        dt = max(0.0, min(0.05, tt - self._ttPrev))
        self._ttPrev = tt
        st = self.state
        self.level += (self.levelTarget - self.level) * 0.25
        self.spk += ((self.level if st == "speaking" else 0.0) - self.spk) * 0.08

        target = {"idle": 0.18, "listening": 0.7, "processing": 0.55}.get(
            st, 0.55 + 0.45 * self.level)
        self.energy += (target - self.energy) * 0.06
        if st == "speaking":
            self.levelTarget *= 0.92

        sqTarget = 0.040 * math.sin(tt * 1.6) + 0.012 * math.sin(tt * 2.7 + 1.3)
        bobBase = 0.018 * math.sin(tt * 1.1) + 0.006 * math.sin(tt * 0.61 + 0.5)

        if look_target is not None:                      # gaze toward the cursor
            ltx = max(-1.0, min(1.0, look_target[0])) * 0.42
            lty = max(-1.0, min(1.0, look_target[1])) * 0.34
        else:                                            # gentle idle wander
            ltx = 0.12 * math.sin(tt * 0.5) + 0.05 * math.sin(tt * 1.3)
            lty = 0.09 * math.sin(tt * 0.37)

        if st == "listening":
            bobBase = 0.012 * math.sin(tt * 2.0)
            sqTarget += 0.03
            lty += 0.14
        elif st == "processing":
            bobBase = 0.010 * math.sin(tt * 1.4)
            ltx *= 0.5
        elif st == "speaking":
            sqTarget += self.spk * 0.11                  # smooth swell, no per-syllable hop
            bobBase += self.spk * 0.010

        # gadget body motion — recording PANS the orb left↔right (filming both sides;
        # the eye leads the sweep), clicker shakes it manically, playback does a small
        # typing bob. (gdx, gdy) are normalised body offsets read by the renderer.
        if self.gadget == "recording":
            self.gdx = 0.32 * math.sin(tt * 1.05)
            self.gdy = 0.03 * math.sin(tt * 2.1)
            ltx = 0.85 * math.sin(tt * 1.05)             # gaze sweeps with the camera
            lty = 0.05
        elif self.gadget == "clicker":
            self.gdx = 0.05 * math.sin(tt * 33.0)
            self.gdy = 0.04 * math.sin(tt * 41.0)
        elif self.gadget == "playback":
            self.gdx = 0.0
            self.gdy = -0.03 * abs(math.sin(tt * 6.0))
        else:
            self.gdx = self.gdy = 0.0

        # blink: a quick wink of the shine every few seconds
        self._blinkCd -= dt
        if self._blinkCd <= 0:
            self._blinkCd = 2.6 + np.random.random() * 4.2
            self._blinking = True
            self._blinkAge = 0.0
        if self._blinking:
            self._blinkAge += dt
            h = self._blinkAge / 0.15
            self.blink = 0.0 if h >= 1 else math.sin(h * math.pi)
            if h >= 1:
                self._blinking = False
        else:
            self.blink = 0.0

        # glitch: a rare ~220ms digital blip every few seconds (mirrors dot.js)
        self._glitchCd -= dt
        if self._glitchCd <= 0:
            self._glitchCd = 2.8 + np.random.random() * 4.5
            self._glitching = True
            self._glitchAge = 0.0
        if self._glitching:
            self._glitchAge += dt
            if self._glitchAge >= 0.22:
                self._glitching = False
                self.glitch = 0.0
            else:
                self.glitch = 0.6 + 0.4 * np.random.random()   # flickery intensity
        else:
            self.glitch = 0.0

        # hologram crossfade — blue while thinking, volt otherwise
        self.holo += ((1.0 if st == "processing" else 0.0) - self.holo) * 0.12

        # expression — idle vignettes / doze + ease the eye toward the active target
        self._reactT = max(0.0, self._reactT - dt)
        if self._reactT <= 0.0:
            self._react = None
        self._vigCd -= dt
        if st == "idle" and self._react is None and not self.gadget and self._vigCd <= 0.0:
            self._vigCd = 4.5 + np.random.random() * 5.5      # livelier: a beat every ~4.5–10s
            # a little "doing something" idle beat — an expression + a bit of MOTION
            # (bounce / stretch / squish / startle), so idle never sits still. The
            # control-room dot has a richer gaze-scripted set (dot.js _idleVignette).
            beats = [
                ("happy", 0.030, 0.00), ("excited", 0.058, -0.06), ("surprised", 0.020, -0.05),
                ("alert", 0.018, 0.02), ("sleepy", 0.006, 0.14), ("suspicious", 0.0, 0.03),
                ("happy", -0.020, 0.05), ("alert", 0.024, 0.00), ("focused", 0.0, 0.03),
                ("surprised", 0.046, -0.05),
            ]
            nm, hop, sqp = beats[int(np.random.random() * len(beats))]
            self.react(nm, 1.1 + np.random.random() * 0.7)
            self.byV += hop
            self.sqV += sqp
            if np.random.random() < 0.5:
                self._blinking = True
                self._blinkAge = 0.0
        if st == "idle" and not self._dozing and not self.gadget and (tt - self._idleSince) > 16.0:
            self._dozing = True                         # nods off after sitting idle
            self.react("sleepy", 4.0)
        # a held gadget pose drives the eye (recording squints, playback focuses),
        # beating the per-state base but still yielding to a transient react()
        tgt = _EXPR[self._react or self._gadgetExpr or self._exprBase]
        for i in range(5):
            self.eye[i] += (tgt[i] - self.eye[i]) * 0.18

        self.sqV += (sqTarget - self.sq) * 0.18
        self.sqV *= 0.80
        self.sq += self.sqV
        self.sq = max(-0.30, min(0.34, self.sq))

        self.byV += (0.0 - self.by) * 0.16
        self.byV *= 0.90
        self.by += self.byV

        self.lookX += (ltx - self.lookX) * 0.09
        self.lookY += (lty - self.lookY) * 0.09

        return (self.sq, bobBase + self.by, self.lookX, self.lookY,
                self.level, self.energy, self.blink, self.glitch, tuple(self.eye),
                self.holo)


# ─────────────────────────────────────────────────────────────────────────────
#  per-pixel-alpha presentation (Win32 UpdateLayeredWindow) on the tk window
# ─────────────────────────────────────────────────────────────────────────────
class _Layered:
    """Drives a top-level window's pixels with UpdateLayeredWindow (per-pixel
    alpha). The HWND is owned by tk; we set WS_EX_LAYERED and blit RGBA frames.

    64-bit safe: every user32/gdi32 call has an explicit argtypes/restype, so
    ctypes does NOT truncate the 64-bit HWND/HDC/HBITMAP handles to 32 bits.
    That truncation is the classic reason a layered overlay silently paints
    nothing (the DC/DIB setup operates on a garbage handle and ULW no-ops, so
    the bare tk window background shows through instead)."""

    def __init__(self, hwnd: int, size) -> None:
        import ctypes
        from ctypes import wintypes
        self._ok = False
        self._closed = False
        # size may be an int (square, the orb) or a (w, h) tuple (the perf OSD)
        if isinstance(size, (tuple, list)):
            self.w, self.h = int(size[0]), int(size[1])
        else:
            self.w = self.h = int(size)
        self.size = self.w                          # back-compat (orb is square)
        self.old = self.hbmp = self.mem_dc = self.screen_dc = None
        try:
            u = ctypes.WinDLL("user32", use_last_error=True)
            g = ctypes.WinDLL("gdi32", use_last_error=True)
            self.u, self.g, self._ctypes = u, g, ctypes

            u.GetAncestor.restype = wintypes.HWND
            u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
            u.GetDC.restype = wintypes.HDC
            u.GetDC.argtypes = [wintypes.HWND]
            u.ReleaseDC.argtypes = [wintypes.HWND, wintypes.HDC]
            g.CreateCompatibleDC.restype = wintypes.HDC
            g.CreateCompatibleDC.argtypes = [wintypes.HDC]
            g.CreateDIBSection.restype = wintypes.HBITMAP
            g.CreateDIBSection.argtypes = [
                wintypes.HDC, ctypes.c_void_p, wintypes.UINT,
                ctypes.POINTER(ctypes.c_void_p), wintypes.HANDLE, wintypes.DWORD]
            g.SelectObject.restype = wintypes.HGDIOBJ
            g.SelectObject.argtypes = [wintypes.HDC, wintypes.HGDIOBJ]
            g.DeleteObject.argtypes = [wintypes.HGDIOBJ]
            g.DeleteDC.argtypes = [wintypes.HDC]
            u.UpdateLayeredWindow.restype = wintypes.BOOL
            u.UpdateLayeredWindow.argtypes = [
                wintypes.HWND, wintypes.HDC, ctypes.POINTER(_POINT),
                ctypes.POINTER(_SIZE), wintypes.HDC, ctypes.POINTER(_POINT),
                wintypes.DWORD, ctypes.POINTER(_BLEND), wintypes.DWORD]
            try:                                   # *PtrW on 64-bit, plain on 32-bit
                getL, setL, LT = u.GetWindowLongPtrW, u.SetWindowLongPtrW, ctypes.c_ssize_t
            except AttributeError:
                getL, setL, LT = u.GetWindowLongW, u.SetWindowLongW, ctypes.c_long
            getL.restype = LT
            getL.argtypes = [wintypes.HWND, ctypes.c_int]
            setL.restype = LT
            setL.argtypes = [wintypes.HWND, ctypes.c_int, LT]

            root = u.GetAncestor(hwnd, 2) or hwnd          # GA_ROOT
            self.hwnd = root
            GWL_EXSTYLE, WS_EX_LAYERED = -20, 0x00080000
            ex = getL(root, GWL_EXSTYLE)
            setL(root, GWL_EXSTYLE, ex | WS_EX_LAYERED)

            bmih = _BMIH()
            bmih.biSize = ctypes.sizeof(_BMIH)
            bmih.biWidth = self.w
            bmih.biHeight = -self.h                        # top-down
            bmih.biPlanes = 1
            bmih.biBitCount = 32
            bmih.biCompression = 0                         # BI_RGB
            self._bits = ctypes.c_void_p()
            self.screen_dc = u.GetDC(None)
            self.mem_dc = g.CreateCompatibleDC(self.screen_dc)
            self.hbmp = g.CreateDIBSection(
                self.mem_dc, ctypes.byref(bmih), 0, ctypes.byref(self._bits), None, 0)
            self.old = g.SelectObject(self.mem_dc, self.hbmp)
            self._ok = bool(self.hbmp and self._bits and self.mem_dc and self.screen_dc)
        except Exception:
            self._ok = False

    def present(self, img: Image.Image) -> bool:
        if not self._ok:
            return False
        ctypes = self._ctypes
        arr = np.asarray(img.convert("RGBA"))
        if arr.shape[0] != self.h or arr.shape[1] != self.w:
            arr = np.asarray(img.resize((self.w, self.h)).convert("RGBA"))
        a = arr[:, :, 3].astype(np.uint16)
        b = (arr[:, :, 2] * a // 255).astype(np.uint8)
        g = (arr[:, :, 1] * a // 255).astype(np.uint8)
        r = (arr[:, :, 0] * a // 255).astype(np.uint8)
        bgra = np.ascontiguousarray(np.dstack([b, g, r, arr[:, :, 3]]).astype(np.uint8))
        ctypes.memmove(self._bits, bgra.ctypes.data, self.w * self.h * 4)

        size = _SIZE(self.w, self.h)
        src = _POINT(0, 0)
        blend = _BLEND(0, 0, 255, 1)               # AC_SRC_OVER, per-pixel AC_SRC_ALPHA
        ULW_ALPHA = 0x02
        return bool(self.u.UpdateLayeredWindow(
            self.hwnd, self.screen_dc, None, ctypes.byref(size),
            self.mem_dc, ctypes.byref(src), 0, ctypes.byref(blend), ULW_ALPHA))

    def close(self) -> None:
        if self._closed:
            return
        self._closed = True
        self._ok = False
        try:
            if self.old and self.mem_dc:
                self.g.SelectObject(self.mem_dc, self.old)
            if self.hbmp:
                self.g.DeleteObject(self.hbmp)
            if self.mem_dc:
                self.g.DeleteDC(self.mem_dc)
            if self.screen_dc:
                self.u.ReleaseDC(None, self.screen_dc)
        except Exception:
            pass


class BallOverlay:
    """A frameless, topmost, GENUINELY transparent floating orb (per-pixel alpha
    via UpdateLayeredWindow). The window + input are a tk Toplevel on the sidecar
    root; set_state / set_level are thread-safe attribute writes."""

    def __init__(self, root, size: int = 150, fps: int = 30,
                 on_restore: Optional[Callable] = None,
                 on_full: Optional[Callable] = None,
                 on_hide: Optional[Callable] = None,
                 on_close: Optional[Callable] = None) -> None:
        self.root = root
        self.size = int(size)
        self._frame_ms = max(16, int(1000 / max(1, fps)))
        self._phys = _Physics()
        self._renderer = _BallRenderer(self.size)
        self._outfit = {"work": False, "low": False, "music": False,
        "gaming": False, "video": False, "coding": False,
        "call": False, "design": False, "shopping": False}
        self._gadget = ""           # app-state pose: recording / clicker / playback
        self._cb = {"restore": on_restore, "full": on_full,
        "hide": on_hide, "close": on_close}
        self._win = None
        self._layered: Optional[_Layered] = None
        self._after = None
        self._running = False
        self._t0 = time.monotonic()
        self._pos = None
        self._drag = None
        self._rhwnd = None          # cached root HWND (for SetWindowPos topmost)
        self._last_top = 0.0        # throttle for periodic topmost re-assert
        self._peek_enabled = True   # "melt into the wallpaper" when desktop is focused
        self._peek_hidden = False   # currently hidden because the desktop is focused
        self._last_peek_chk = 0.0
        self._build()

    # ── construction ─────────────────────────────────────────────────────────
    def _build(self) -> None:
        import tkinter as tk
        win = tk.Toplevel(self.root)
        win.withdraw()
        win.overrideredirect(True)
        win.attributes("-topmost", True)
        try:
            win.wm_attributes("-toolwindow", True)
        except Exception:
            pass
        win.geometry(f"{self.size}x{self.size}+0+0")
        self._win = win
        self._wire(win)

    def _wire(self, win) -> None:
        def _press(e):
            self._drag = {"rx": e.x_root, "ry": e.y_root,
            "wx": win.winfo_x(), "wy": win.winfo_y(), "moved": False}

        def _motion(e):
            d = self._drag
            if not d:
                return
            dx, dy = e.x_root - d["rx"], e.y_root - d["ry"]
            if abs(dx) > 3 or abs(dy) > 3:
                d["moved"] = True
                nx, ny = d["wx"] + dx, d["wy"] + dy
                win.geometry(f"+{nx}+{ny}")
                self._pos = (nx, ny)

        def _release(e):
            d = self._drag
            self._drag = None
            if d and not d["moved"]:            # a click (not a drag) → control room
                self._fire("restore")

        win.bind("<ButtonPress-1>", _press)
        win.bind("<B1-Motion>", _motion)
        win.bind("<ButtonRelease-1>", _release)

        # right-click → hide the overlay (the full overlay was removed; left-click
        # still opens the control room, drag still moves it)
        win.bind("<Button-3>", lambda e: self._fire("hide"))

    def _fire(self, name: str) -> None:
        cb = self._cb.get(name)
        if cb:
            try:
                cb()
            except Exception:
                pass

    # ── public surface (thread-safe attr writes) ─────────────────────────────
    def set_state(self, s: str) -> None:
        self._phys.set_state(s)

    def set_level(self, v: float) -> None:
        self._phys.set_level(v)

    def react(self, name: str, hold: float = 0.7) -> None:
        """Play a transient eye expression (e.g. 'happy', 'surprised', 'suspicious',
        'sleepy') — wire to events to make the orb feel alive."""
        self._phys.react(name, hold)

    def set_outfit(self, work=None, low=None, music=None) -> None:
        """Toggle the mascot's outfits (None = leave that one unchanged):
        work → amber tie, low → sleepy cap, music → headphones."""
        if work is not None:
            self._outfit["work"] = bool(work)
        if low is not None:
            self._outfit["low"] = bool(low)
        if music is not None:
            self._outfit["music"] = bool(music)

    @staticmethod
    def _pick(active, order):
        """First label in `order` that's active (slot priority) — '' if none."""
        for a in order:
            if a in active:
                return a
        return ""

    def set_activity(self, name) -> None:
        """Context-aware outfits — accepts ONE label or several (comma-string or a
        list) so they layer by body slot: head  music / ️ call, face  coding,
        hand  gaming /  video /  design / ️ shopping. One winner per slot
        (call beats music on the head; gaming > video > design > shopping in hand)
        so the ball never wears two head props or holds two things. '' → none."""
        if isinstance(name, (list, tuple, set)):
            active = {str(x).strip() for x in name if str(x).strip()}
        else:
            active = {p.strip() for p in str(name or "").split(",") if p.strip()}
        head = self._pick(active, ("call", "music"))
        hand = self._pick(active, ("gaming", "video", "design", "shopping"))
        face = "coding" if "coding" in active else ""
        chosen = {x for x in (head, hand, face) if x}
        for k in ("music", "gaming", "video", "coding", "call", "design", "shopping"):
            self._outfit[k] = (k in chosen)

    def set_gadget(self, name) -> None:
        """App-state pose layered over the orb, SEPARATE from the activity outfits:
            "recording" (camcorder + viewfinder squint, the orb pans left↔right) ·
            "clicker" (frantic mouse + jabbing finger) · "playback" (key-ripple
        keyboard) · "" clears it. While a gadget is active the activity/work
        outfits are hidden so the pose reads clean (matches the control-room dot)."""
        self._gadget = str(name or "")
        self._phys.set_gadget(self._gadget)

    def show(self, state: Optional[str] = None) -> None:
        if self._win is None:
            return
        self._peek_hidden = False       # a fresh show is always visible
        self._last_peek_chk = 0.0
        if state:
            self._phys.set_state(state)
        if self._pos is None:
            sw = self._win.winfo_screenwidth()
            sh = self._win.winfo_screenheight()
            # the ball is centred in a larger (mostly transparent) window; anchor by
            # the ball's edge (~0.25·size padding) so it still sits in the corner
            edge = int(self.size * 0.75)
            self._pos = (sw - edge - 24, sh - edge - 64)
        x, y = self._pos
        self._win.geometry(f"{self.size}x{self.size}+{x}+{y}")
        self._win.deiconify()
        self._win.update_idletasks()
        self._win.lift()
        try:
            self._win.attributes("-topmost", True)
        except Exception:
            pass
        self._rhwnd = None          # re-resolve after deiconify (HWND can change)
        self._assert_topmost()
        self._last_top = time.monotonic()
        if self._layered is None:
            try:
                self._layered = _Layered(self._win.winfo_id(), self.size)
            except Exception:
                self._layered = None
        if not self._running:
            self._running = True
            self._t0 = time.monotonic()
            self._tick()

    def hide(self) -> None:
        self._running = False
        self._peek_hidden = False
        if self._after is not None:
            try:
                self.root.after_cancel(self._after)
            except Exception:
                pass
            self._after = None
        if self._win is not None:
            try:
                self._win.withdraw()
            except Exception:
                pass

    def set_desktop_peek(self, enabled: bool) -> None:
        """Toggle the "melt into the wallpaper when the desktop is focused" effect."""
        self._peek_enabled = bool(enabled)
        if not enabled and self._peek_hidden and self._running:
            self._peek_hidden = False
            try:
                self._win.deiconify(); self._rhwnd = None; self._assert_topmost()
            except Exception:
                pass

    def _update_peek(self) -> None:
        """Hide the orb while the Windows desktop is the focused window (so it reads
        as the orb having dropped into the live wallpaper); restore it on any app."""
        try:
            import wivexx_activity
            on_desktop = wivexx_activity.is_desktop_foreground()
        except Exception:
            on_desktop = False
        if on_desktop and not self._peek_hidden:
            self._peek_hidden = True
            try:
                self._win.withdraw()
            except Exception:
                pass
        elif (not on_desktop) and self._peek_hidden:
            self._peek_hidden = False
            try:
                self._win.deiconify()
                self._rhwnd = None
                self._assert_topmost()
                self._last_top = time.monotonic()
            except Exception:
                pass

    # ── always-on-top enforcement ────────────────────────────────────────────
    def _root_hwnd(self):
        """The top-level (root) HWND for the overlay — what SetWindowPos needs.
        tk's winfo_id() is the child window; walk up to GA_ROOT once and cache."""
        if self._rhwnd:
            return self._rhwnd
        try:
            import ctypes
            from ctypes import wintypes
            u = ctypes.windll.user32
            u.GetAncestor.restype = wintypes.HWND
            u.GetAncestor.argtypes = [wintypes.HWND, wintypes.UINT]
            wid = self._win.winfo_id()
            self._rhwnd = u.GetAncestor(wid, 2) or wid       # GA_ROOT = 2
        except Exception:
            self._rhwnd = None
        return self._rhwnd

    def _assert_topmost(self):
        """Re-glue the orb to the top of the z-order. Windows silently demotes a
        topmost window when another app goes foreground/fullscreen or claims
        topmost itself, so we re-assert on show + ~once a second while running.
        SWP_NOACTIVATE keeps focus where it is (no stealing)."""
        try:
            import ctypes
            from ctypes import wintypes
            hwnd = self._root_hwnd()
            if not hwnd:
                return
            u = ctypes.windll.user32
            u.SetWindowPos.restype = wintypes.BOOL
            u.SetWindowPos.argtypes = [
                wintypes.HWND, wintypes.HWND, ctypes.c_int, ctypes.c_int,
                ctypes.c_int, ctypes.c_int, wintypes.UINT]
            HWND_TOPMOST = wintypes.HWND(-1)
            SWP_NOSIZE, SWP_NOMOVE, SWP_NOACTIVATE = 0x0001, 0x0002, 0x0010
            u.SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0,
                           SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE)
        except Exception:
            pass

    # ── animation loop (sidecar/tk thread) ───────────────────────────────────
    def _cursor_look(self):
        """Look-target from the cursor relative to the window centre (-1..1)."""
        try:
            import ctypes
            pt = ctypes.wintypes.POINT() if hasattr(ctypes, "wintypes") else None
        except Exception:
            return None
        try:
            import ctypes
            from ctypes import wintypes
            p = wintypes.POINT()
            ctypes.windll.user32.GetCursorPos(ctypes.byref(p))
            wx = self._win.winfo_rootx() + self.size / 2
            wy = self._win.winfo_rooty() + self.size / 2
            half = self.size * 0.5
            dx = (p.x - wx) / (half * 2.2)
            dy = (wy - p.y) / (half * 2.2)        # y up
            return (dx, dy)
        except Exception:
            return None

    def _tick(self) -> None:
        if not self._running or self._win is None:
            return
        try:
            now = time.monotonic()
            tt = now - self._t0
            # "melt into the wallpaper": hide when the user clicks the DESKTOP, come
            # back on any app window (checked a few times/sec, not every frame)
            if self._peek_enabled and now - self._last_peek_chk >= 0.3:
                self._last_peek_chk = now
                self._update_peek()
            if self._peek_hidden:
                self._after = self.root.after(self._frame_ms, self._tick)
                return                              # merged into the desktop — don't draw
            if now - self._last_top >= 0.5:        # keep it pinned to the top
                self._last_top = now
                self._assert_topmost()
            look = self._cursor_look()
            if look is not None:
                self._phys.notice_cursor(look)         # perk up / startle reactions
            sq, bob, lx, ly, lvl, energy, blink, glitch, eye, holo = self._phys.step(
                tt, look_target=look)
            img = self._renderer.frame(self._phys.state, tt, squash=sq, bob=bob,
                                       look=(lx, ly), level=lvl, blink=blink,
                                       energy=energy, glitch=glitch, eye=eye, holo=holo,
                                       work=self._outfit["work"], low=self._outfit["low"],
                                       music=self._outfit["music"], gaming=self._outfit["gaming"],
                                       video=self._outfit["video"], coding=self._outfit["coding"],
                                       call=self._outfit["call"], design=self._outfit["design"],
                                       shopping=self._outfit["shopping"],
                                       gadget=self._gadget, body_dx=self._phys.gdx,
                                       body_dy=self._phys.gdy)
            if self._layered is not None:
                self._layered.present(img)
        except Exception:
            pass
        try:
            self._after = self.root.after(self._frame_ms, self._tick)
        except Exception:
            self._running = False


if __name__ == "__main__":
    import os
    out = os.path.dirname(os.path.abspath(__file__))
    r = _BallRenderer(180)
    r.frame("idle", 0.0, 0.0, 0.0)                          # warm cache
    for name, (st, sq, bob, lvl) in {
    "idle": ("idle", 0.0, 0.02, 0.0),
    "listening": ("listening", 0.03, 0.01, 0.0),
    "processing": ("processing", 0.0, 0.01, 0.0),
    "speaking": ("speaking", -0.10, 0.05, 0.7),
    }.items():
        fr = r.frame(st, 0.6, sq, bob, look=(0.4, 0.3), level=lvl, energy=0.6)
        c = Image.new("RGBA", fr.size, (40, 44, 52, 255))   # fake desktop to show alpha
        c.alpha_composite(fr)
        c.convert("RGB").save(os.path.join(out, f"_ball_{name}.png"))
    n = 60
    t0 = time.perf_counter()
    for i in range(n):
        r.frame("speaking", i / 30.0, 0.1 * math.sin(i), 0.02, look=(0.2, 0.1), level=0.6)
    print(f"wrote _ball_*.png  |  steady-state frame: {(time.perf_counter()-t0)/n*1000:.1f} ms")
