"""Procedural LED effect stream — fire, meteor, plasma, noise, aurora.

Implements EffectColorStripStream which produces animated LED color arrays
using purely algorithmic rendering (no capture source).  Each effect is a
render method dispatched by effect_type.

Palette LUTs and a simple 1-D value-noise generator are included so that
no external dependencies are required.
"""

import math
import threading
import time
from typing import Dict, Optional

import numpy as np

from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer

logger = get_logger(__name__)

# ── Palette LUT system ──────────────────────────────────────────────────

# Each palette is a list of (position, R, G, B) control points.
# Positions must be monotonically increasing from 0.0 to 1.0.
_PALETTE_DEFS: Dict[str, list] = {
    "fire":    [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)],
    "ocean":   [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)],
    "lava":    [(0, 0, 0, 0), (0.25, 128, 0, 0), (0.5, 255, 32, 0), (0.75, 255, 160, 0), (1.0, 255, 255, 128)],
    "forest":  [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
    "rainbow": [(0, 255, 0, 0), (0.17, 255, 255, 0), (0.33, 0, 255, 0),
                (0.5, 0, 255, 255), (0.67, 0, 0, 255), (0.83, 255, 0, 255), (1.0, 255, 0, 0)],
    "aurora":  [(0, 0, 16, 32), (0.2, 0, 80, 64), (0.4, 0, 200, 100),
                (0.6, 64, 128, 255), (0.8, 128, 0, 200), (1.0, 0, 16, 32)],
    "sunset":  [(0, 32, 0, 64), (0.25, 128, 0, 128), (0.5, 255, 64, 0),
                (0.75, 255, 192, 64), (1.0, 255, 255, 192)],
    "ice":     [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)],
}

_palette_cache: Dict[str, np.ndarray] = {}


def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
    """Build a (256, 3) uint8 lookup table for the named palette.

    When name == "custom" and custom_stops is provided, builds from those
    stops without caching (each source gets its own LUT).
    """
    if custom_stops and name == "custom":
        # Convert [[pos,R,G,B], ...] to [(pos,R,G,B), ...]
        points = [(s[0], s[1], s[2], s[3]) for s in custom_stops if len(s) >= 4]
        if not points:
            points = _PALETTE_DEFS["fire"]
    else:
        if name in _palette_cache:
            return _palette_cache[name]
        points = _PALETTE_DEFS.get(name, _PALETTE_DEFS["fire"])
    lut = np.zeros((256, 3), dtype=np.uint8)
    for i in range(256):
        t = i / 255.0
        # Find surrounding control points
        a_idx = 0
        for j in range(len(points) - 1):
            if points[j + 1][0] >= t:
                a_idx = j
                break
        else:
            a_idx = len(points) - 2
        a_pos, ar, ag, ab = points[a_idx]
        b_pos, br, bg, bb = points[a_idx + 1]
        span = b_pos - a_pos
        frac = (t - a_pos) / span if span > 0 else 0.0
        lut[i] = (
            int(ar + (br - ar) * frac),
            int(ag + (bg - ag) * frac),
            int(ab + (bb - ab) * frac),
        )
    if name != "custom":
        _palette_cache[name] = lut
    return lut


# ── 1-D value noise (no external deps) ──────────────────────────────────

class _ValueNoise1D:
    """Simple 1-D value noise with smoothstep interpolation and fractal octaves.

    Scratch buffers are lazily allocated and reused across calls to avoid
    per-frame numpy allocations in hot loops.
    """

    def __init__(self, seed: int = 42):
        rng = np.random.RandomState(seed)
        self._table = rng.random(512).astype(np.float32)
        self._scratch_n = 0

    def _ensure_scratch(self, n: int) -> None:
        """(Re)allocate scratch buffers when array size changes."""
        if n == self._scratch_n:
            return
        self._scratch_n = n
        self._xi = np.empty(n, dtype=np.int64)
        self._frac = np.empty(n, dtype=np.float32)
        self._t = np.empty(n, dtype=np.float32)
        self._a = np.empty(n, dtype=np.float32)
        self._b = np.empty(n, dtype=np.float32)
        self._oct_x = np.empty(n, dtype=np.float32)
        self._fbm_result = np.empty(n, dtype=np.float32)

    def noise(self, x: np.ndarray) -> np.ndarray:
        """Single-octave smooth noise for an array of float positions.

        Returns an internal buffer (_b) — caller must copy if the result
        is needed beyond the next noise() or fbm() call.
        """
        n = len(x)
        self._ensure_scratch(n)
        size = len(self._table)
        # xi = floor(x)
        np.floor(x, out=self._frac)
        np.copyto(self._xi, self._frac, casting='unsafe')
        # frac = x - xi
        np.subtract(x, self._frac, out=self._frac)
        # t = frac * frac * (3 - 2 * frac)  (smoothstep)
        np.multiply(self._frac, self._frac, out=self._t)
        np.multiply(self._frac, -2.0, out=self._a)
        self._a += 3.0
        self._t *= self._a
        # Table lookups (fancy indexing is unavoidable but copies into pre-allocated)
        np.remainder(self._xi, size, out=self._xi)
        self._a[:] = self._table[self._xi]
        self._xi += 1
        np.remainder(self._xi, size, out=self._xi)
        self._b[:] = self._table[self._xi]
        # result = a + t * (b - a)
        self._b -= self._a
        self._b *= self._t
        self._b += self._a
        return self._b

    def fbm(self, x: np.ndarray, octaves: int = 3) -> np.ndarray:
        """Fractal Brownian Motion — layered noise at decreasing amplitude.

        Returns an internal buffer (_fbm_result) — caller must copy if the
        result is needed beyond the next fbm() call.
        """
        n = len(x)
        self._ensure_scratch(n)
        self._fbm_result[:] = 0
        amp = 1.0
        freq = 1.0
        total_amp = 0.0
        for _ in range(octaves):
            np.multiply(x, freq, out=self._oct_x)
            self.noise(self._oct_x)
            # noise() result is in self._b; copy to _a for accumulation
            self._a[:] = self._b
            self._a *= amp
            self._fbm_result += self._a
            total_amp += amp
            amp *= 0.5
            freq *= 2.0
        self._fbm_result /= total_amp
        return self._fbm_result


# ── Effect stream ────────────────────────────────────────────────────────

# Default palette per effect type
_EFFECT_DEFAULT_PALETTE = {
    "fire": "fire",
    "meteor": "fire",
    "plasma": "rainbow",
    "noise": "rainbow",
    "aurora": "aurora",
    "rain": "ocean",
    "comet": "fire",
    "bouncing_ball": "rainbow",
    "fireworks": "rainbow",
    "sparkle_rain": "ice",
    "lava_lamp": "lava",
    "wave_interference": "rainbow",
}


class EffectColorStripStream(ColorStripStream):
    """Color strip stream that runs a procedural LED effect.

    Dispatches to one of five render methods based on effect_type:
    fire, meteor, plasma, noise, aurora.

    Uses the same lifecycle pattern as StaticColorStripStream:
    background thread, double-buffered output, configure() for auto-sizing.
    """

    def __init__(self, source):
        self._colors_lock = threading.Lock()
        self._running = False
        self._thread: Optional[threading.Thread] = None
        self._fps = 30
        self._frame_time = 1.0 / 30
        self._clock = None  # optional SyncClockRuntime
        self._effective_speed = 1.0  # resolved speed (from clock or source)
        self._noise = _ValueNoise1D(seed=42)
        # Fire state — allocated lazily in render loop
        self._heat: Optional[np.ndarray] = None
        self._heat_n = 0
        # Scratch arrays (allocated in _animate_loop when LED count is known)
        self._s_f32_a: Optional[np.ndarray] = None
        self._s_f32_b: Optional[np.ndarray] = None
        self._s_f32_c: Optional[np.ndarray] = None
        self._s_i32: Optional[np.ndarray] = None
        self._s_f32_rgb: Optional[np.ndarray] = None
        self._s_arange: Optional[np.ndarray] = None
        self._s_layer1: Optional[np.ndarray] = None
        self._s_layer2: Optional[np.ndarray] = None
        self._plasma_key = (0, 0.0)
        self._plasma_x: Optional[np.ndarray] = None
        # Bouncing ball state
        self._ball_positions: Optional[np.ndarray] = None
        self._ball_velocities: Optional[np.ndarray] = None
        self._ball_last_t = 0.0
        # Fireworks state
        self._fw_particles: list = []  # active particles
        self._fw_rockets: list = []    # active rockets
        self._fw_last_launch = 0.0
        # Sparkle rain state
        self._sparkle_state: Optional[np.ndarray] = None  # per-LED brightness 0..1
        self._gradient_store = None  # injected by stream manager
        self._update_from_source(source)

    def set_gradient_store(self, gradient_store) -> None:
        """Inject gradient store for palette resolution. Called by stream manager."""
        self._gradient_store = gradient_store
        # Re-resolve palette now that store is available
        self._resolve_palette_lut()

    def _resolve_palette_lut(self) -> None:
        """Build palette LUT from gradient_id or legacy palette name."""
        gradient_id = self._gradient_id
        if gradient_id and self._gradient_store:
            stops = self._gradient_store.resolve_stops(gradient_id)
            if stops:
                # Convert gradient entity stops to palette LUT stops
                custom = [[s["position"], *s["color"]] for s in stops]
                self._palette_lut = _build_palette_lut("custom", custom)
                return
        # Fallback: legacy palette name or custom_palette
        self._palette_lut = _build_palette_lut(self._palette_name, self._custom_palette)

    def _update_from_source(self, source) -> None:
        self._effect_type = getattr(source, "effect_type", "fire")
        _lc = getattr(source, "led_count", 0)
        self._auto_size = not _lc
        self._led_count = _lc if _lc and _lc > 0 else 1
        self._gradient_id = getattr(source, "gradient_id", None)
        self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire")
        self._custom_palette = getattr(source, "custom_palette", None)
        self._resolve_palette_lut()
        color = getattr(source, "color", None)
        self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
        self._intensity = float(getattr(source, "intensity", 1.0))
        self._scale = float(getattr(source, "scale", 1.0))
        self._mirror = bool(getattr(source, "mirror", False))
        with self._colors_lock:
            self._colors: Optional[np.ndarray] = None

    def configure(self, device_led_count: int) -> None:
        if self._auto_size and device_led_count > 0:
            new_count = max(self._led_count, device_led_count)
            if new_count != self._led_count:
                self._led_count = new_count
                logger.debug(f"EffectColorStripStream auto-sized to {new_count} LEDs")

    @property
    def target_fps(self) -> int:
        return self._fps

    @property
    def led_count(self) -> int:
        return self._led_count

    def set_capture_fps(self, fps: int) -> None:
        self._fps = max(1, min(90, fps))
        self._frame_time = 1.0 / self._fps

    def start(self) -> None:
        if self._running:
            return
        self._running = True
        self._thread = threading.Thread(
            target=self._animate_loop,
            name=f"css-effect-{self._effect_type}",
            daemon=True,
        )
        self._thread.start()
        logger.info(f"EffectColorStripStream started (effect={self._effect_type}, leds={self._led_count})")

    def stop(self) -> None:
        self._running = False
        if self._thread:
            self._thread.join(timeout=5.0)
            if self._thread.is_alive():
                logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
            self._thread = None
        self._heat = None
        self._heat_n = 0
        logger.info("EffectColorStripStream stopped")

    def get_latest_colors(self) -> Optional[np.ndarray]:
        with self._colors_lock:
            return self._colors

    def update_source(self, source) -> None:
        from wled_controller.storage.color_strip_source import EffectColorStripSource
        if isinstance(source, EffectColorStripSource):
            prev_led_count = self._led_count if self._auto_size else None
            self._update_from_source(source)
            if prev_led_count and self._auto_size:
                self._led_count = prev_led_count
            logger.info("EffectColorStripStream params updated in-place")

    def set_clock(self, clock) -> None:
        """Set or clear the sync clock runtime. Thread-safe (read atomically by loop)."""
        self._clock = clock

    # ── Main animation loop ──────────────────────────────────────────

    def _animate_loop(self) -> None:
        _pool_n = 0
        _buf_a = _buf_b = None
        _use_a = True

        # Dispatch table
        renderers = {
            "fire": self._render_fire,
            "meteor": self._render_meteor,
            "plasma": self._render_plasma,
            "noise": self._render_noise,
            "aurora": self._render_aurora,
            "rain": self._render_rain,
            "comet": self._render_comet,
            "bouncing_ball": self._render_bouncing_ball,
            "fireworks": self._render_fireworks,
            "sparkle_rain": self._render_sparkle_rain,
            "lava_lamp": self._render_lava_lamp,
            "wave_interference": self._render_wave_interference,
        }

        try:
            with high_resolution_timer():
                while self._running:
                    wall_start = time.perf_counter()
                    frame_time = self._frame_time
                    try:
                        # Resolve animation time and speed from clock or local
                        clock = self._clock
                        if clock:
                            if not clock.is_running:
                                # Clock paused — output frozen, sleep and skip render
                                time.sleep(0.1)
                                continue
                            anim_time = clock.get_time()
                            self._effective_speed = clock.speed
                        else:
                            anim_time = wall_start
                            self._effective_speed = 1.0

                        n = self._led_count
                        if n != _pool_n:
                            _pool_n = n
                            _buf_a = np.empty((n, 3), dtype=np.uint8)
                            _buf_b = np.empty((n, 3), dtype=np.uint8)
                            # Scratch arrays for render methods
                            self._s_f32_a = np.empty(n, dtype=np.float32)
                            self._s_f32_b = np.empty(n, dtype=np.float32)
                            self._s_f32_c = np.empty(n, dtype=np.float32)
                            self._s_i32 = np.empty(n, dtype=np.int32)
                            self._s_f32_rgb = np.empty((n, 3), dtype=np.float32)
                            self._s_arange = np.arange(n, dtype=np.float32)
                            self._s_layer1 = np.empty(n, dtype=np.float32)
                            self._s_layer2 = np.empty(n, dtype=np.float32)
                            self._plasma_key = (0, 0.0)

                        buf = _buf_a if _use_a else _buf_b
                        _use_a = not _use_a

                        render_fn = renderers.get(self._effect_type, self._render_fire)
                        render_fn(buf, n, anim_time)

                        with self._colors_lock:
                            self._colors = buf
                    except Exception as e:
                        logger.error(f"EffectColorStripStream render error: {e}")

                    elapsed = time.perf_counter() - wall_start
                    time.sleep(max(frame_time - elapsed, 0.001))
        except Exception as e:
            logger.error(f"Fatal EffectColorStripStream loop error: {e}", exc_info=True)
        finally:
            self._running = False

    # ── Fire ─────────────────────────────────────────────────────────

    def _render_fire(self, buf: np.ndarray, n: int, t: float) -> None:
        """Heat-propagation fire simulation.

        A 1-D heat array cools, diffuses upward, and receives random sparks
        at the bottom.  Heat values are mapped to the palette LUT.
        """
        speed = self._effective_speed
        intensity = self._intensity
        lut = self._palette_lut

        # (Re)allocate heat array when LED count changes
        if self._heat is None or self._heat_n != n:
            self._heat = np.zeros(n, dtype=np.float32)
            self._heat_n = n

        heat = self._heat

        # Cooling: proportional to strip length so larger strips don't go dark
        cooling = 0.02 * speed * (60.0 / max(n, 1))
        heat -= cooling
        np.clip(heat, 0.0, 1.0, out=heat)

        # Diffuse heat upward (index 0 = bottom, index n-1 = top)
        if n >= 3:
            new_heat = self._s_f32_a
            new_heat[0] = (heat[0] + heat[1]) * 0.5
            new_heat[1:-1] = (heat[:-2] + heat[1:-1] + heat[2:]) / 3.0
            new_heat[-1] = heat[-1] * 0.5
            heat[:] = new_heat

        # Sparks at the bottom (vectorized)
        spark_zone = max(1, n // 8)
        spark_prob = 0.3 * intensity
        rng = np.random.random(spark_zone)
        mask = rng < spark_prob
        if mask.any():
            heat[:spark_zone] = np.where(
                mask,
                np.minimum(1.0, heat[:spark_zone] + 0.4 + 0.6 * np.random.random(spark_zone)),
                heat[:spark_zone],
            )

        # Map heat to palette (pre-allocated scratch)
        np.multiply(heat, 255, out=self._s_f32_a)
        np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
        np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
        buf[:] = lut[self._s_i32]

    # ── Meteor ───────────────────────────────────────────────────────

    def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
        """Bright meteor head with exponential-decay trail."""
        speed = self._effective_speed
        intensity = self._intensity
        color = self._color
        mirror = self._mirror

        # Compute position along the strip
        travel_speed = speed * 8.0  # LEDs per second
        if mirror:
            # Bounce: position ping-pongs 0→n-1→0
            cycle = 2 * (n - 1) if n > 1 else 1
            raw_pos = (t * travel_speed) % cycle
            pos = raw_pos if raw_pos < n else cycle - raw_pos
        else:
            pos = (t * travel_speed) % n

        # Tail decay factor: intensity controls how long the tail is
        # Higher intensity → slower decay → longer tail
        decay = 0.05 + 0.25 * (1.0 - min(1.0, intensity))  # 0.05 (long) to 0.30 (short)

        # Compute brightness for each LED based on distance behind the meteor
        indices = self._s_arange
        dist = self._s_f32_a
        if mirror:
            np.subtract(indices, pos, out=dist)
            np.abs(dist, out=dist)
        else:
            np.subtract(pos, indices, out=dist)
            dist %= n

        np.multiply(dist, -decay, out=self._s_f32_b)
        np.exp(self._s_f32_b, out=self._s_f32_b)
        brightness = self._s_f32_b

        # Apply color using pre-allocated scratch
        r, g, b = color
        np.multiply(brightness, r, out=self._s_f32_c)
        np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
        np.copyto(buf[:, 0], self._s_f32_c, casting='unsafe')
        np.multiply(brightness, g, out=self._s_f32_c)
        np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
        np.copyto(buf[:, 1], self._s_f32_c, casting='unsafe')
        np.multiply(brightness, b, out=self._s_f32_c)
        np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
        np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe')

        # Bright white-ish head (2-3 LEDs) — direct index range to avoid
        # boolean mask allocations and fancy indexing temporaries.
        head_lo = max(0, int(pos - 1.5) + 1)
        head_hi = min(n, int(pos + 1.5) + 1)
        if head_hi > head_lo:
            head_sl = slice(head_lo, head_hi)
            head_dist = self._s_f32_a[head_sl]
            np.subtract(indices[head_sl], pos, out=head_dist)
            np.abs(head_dist, out=head_dist)
            # head_brightness = clip(1 - abs_dist, 0, 1)
            head_br = self._s_f32_b[head_sl]
            np.subtract(1.0, head_dist, out=head_br)
            np.clip(head_br, 0, 1, out=head_br)
            # Additive blend towards white using scratch _s_f32_c slice
            tmp = self._s_f32_c[head_sl]
            for ch_idx, ch_base in enumerate((r, g, b)):
                np.multiply(head_br, 255 - ch_base, out=tmp)
                tmp += buf[head_sl, ch_idx]
                np.clip(tmp, 0, 255, out=tmp)
                np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe')

    # ── Plasma ───────────────────────────────────────────────────────

    def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
        """Overlapping sine waves creating colorful plasma patterns."""
        speed = self._effective_speed
        scale = self._scale
        lut = self._palette_lut

        # Cache x array (only changes when n or scale change)
        key = (n, scale)
        if key != self._plasma_key:
            self._plasma_key = key
            self._plasma_x = np.linspace(0, scale * math.pi * 2, n, dtype=np.float64)

        phase = t * speed * 0.5
        x = self._plasma_x

        v = (
            np.sin(x + phase)
            + np.sin(x * 0.5 + phase * 1.3)
            + np.sin(x * 0.3 + phase * 0.7)
            + np.sin(x * 0.7 + phase * 1.1)
        )
        # Normalize from [-4, 4] to [0, 255]
        indices = np.clip(((v + 4.0) * (255.0 / 8.0)).astype(np.int32), 0, 255)
        buf[:] = lut[indices]

    # ── Perlin Noise ─────────────────────────────────────────────────

    def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
        """Smooth scrolling fractal noise mapped to a color palette."""
        speed = self._effective_speed
        scale = self._scale
        lut = self._palette_lut

        # Positions from cached arange (avoids per-frame np.arange)
        np.multiply(self._s_arange, scale * 0.1, out=self._s_f32_a)
        self._s_f32_a += t * speed * 0.5
        values = self._noise.fbm(self._s_f32_a, octaves=3)
        # Map to palette indices using pre-allocated scratch
        np.multiply(values, 255, out=self._s_f32_b)
        np.clip(self._s_f32_b, 0, 255, out=self._s_f32_b)
        np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
        buf[:] = lut[self._s_i32]

    # ── Aurora ───────────────────────────────────────────────────────

    def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
        """Layered noise bands simulating aurora borealis."""
        speed = self._effective_speed
        scale = self._scale
        intensity = self._intensity
        lut = self._palette_lut

        # Positions from cached arange
        np.multiply(self._s_arange, scale * 0.08, out=self._s_f32_a)

        # Three noise layers — copy results to dedicated buffers since fbm
        # may return an internal reference that gets overwritten on the next call
        np.add(self._s_f32_a, t * speed * 0.2, out=self._s_f32_b)
        self._s_layer1[:] = self._noise.fbm(self._s_f32_b, octaves=3)

        np.multiply(self._s_f32_a, 1.5, out=self._s_f32_b)
        self._s_f32_b += t * speed * 0.35 + 100.0
        self._s_layer2[:] = self._noise.fbm(self._s_f32_b, octaves=3)

        np.multiply(self._s_f32_a, 0.7, out=self._s_f32_b)
        self._s_f32_b += t * speed * 0.15 + 200.0
        layer3 = self._noise.fbm(self._s_f32_b, octaves=2)

        # Combine layers: hue from layer1 + layer3, brightness from layer2
        hue = self._s_f32_a  # reuse (positions no longer needed)
        np.multiply(layer3, 0.5, out=hue)
        hue += self._s_layer1
        hue *= 0.67
        np.clip(hue, 0.0, 1.0, out=hue)

        bright = self._s_f32_b
        np.multiply(self._s_layer2, 0.7 * intensity, out=bright)
        bright += 0.3
        np.clip(bright, 0.0, 1.0, out=bright)

        # Map to palette using pre-allocated scratch
        np.multiply(hue, 255, out=hue)
        np.copyto(self._s_i32, hue, casting='unsafe')
        np.clip(self._s_i32, 0, 255, out=self._s_i32)
        self._s_f32_rgb[:] = lut[self._s_i32]
        self._s_f32_rgb *= bright[:, np.newaxis]
        np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
        np.copyto(buf, self._s_f32_rgb, casting='unsafe')

    # ── Rain ──────────────────────────────────────────────────────────

    def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
        """Raindrops falling down the strip with trailing tails."""
        speed = self._effective_speed
        intensity = self._intensity
        scale = self._scale
        lut = self._palette_lut

        # Multiple rain "lanes" at different speeds for depth
        bright = self._s_f32_a
        bright[:] = 0.0
        indices = self._s_arange

        num_drops = max(3, int(8 * intensity))
        for d in range(num_drops):
            drop_speed = speed * (6.0 + d * 2.3) * scale
            phase_offset = d * 31.7  # prime-ish offset for independence
            # Drop position wraps around the strip
            pos = (t * drop_speed + phase_offset) % n
            # Tail: exponential falloff behind drop (drop falls downward = decreasing index)
            dist = (pos - indices) % n
            tail_len = max(3.0, n * 0.08)
            trail = np.exp(-dist / tail_len)
            # Head brightness boost
            head_mask = dist < 2.0
            trail[head_mask] = 1.0
            bright += trail * (0.3 / max(num_drops * 0.3, 1.0))

        np.clip(bright, 0.0, 1.0, out=bright)
        np.multiply(bright, 255, out=self._s_f32_b)
        np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
        np.clip(self._s_i32, 0, 255, out=self._s_i32)
        buf[:] = lut[self._s_i32]

    # ── Comet ─────────────────────────────────────────────────────────

    def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
        """Multiple comets with curved, pulsing tails."""
        speed = self._effective_speed
        intensity = self._intensity
        color = self._color
        mirror = self._mirror

        indices = self._s_arange
        buf[:] = 0

        num_comets = 3
        for c in range(num_comets):
            travel_speed = speed * (5.0 + c * 3.0)
            phase = c * 137.5

            if mirror:
                cycle = 2 * (n - 1) if n > 1 else 1
                raw_pos = (t * travel_speed + phase) % cycle
                pos = raw_pos if raw_pos < n else cycle - raw_pos
            else:
                pos = (t * travel_speed + phase) % n

            # Tail with pulsing brightness
            dist = self._s_f32_a
            np.subtract(pos, indices, out=dist)
            dist %= n

            decay = 0.04 + 0.20 * (1.0 - min(1.0, intensity))
            np.multiply(dist, -decay, out=self._s_f32_b)
            np.exp(self._s_f32_b, out=self._s_f32_b)
            # Pulse modulation on tail
            pulse = 0.7 + 0.3 * math.sin(t * speed * 4.0 + c * 2.1)
            self._s_f32_b *= pulse

            r, g, b = color
            for ch_idx, ch_val in enumerate((r, g, b)):
                np.multiply(self._s_f32_b, ch_val, out=self._s_f32_c)
                np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
                # Additive blend
                self._s_f32_a[:] = buf[:, ch_idx]
                self._s_f32_a += self._s_f32_c
                np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
                np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')

    # ── Bouncing Ball ─────────────────────────────────────────────────

    def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
        """Physics-simulated bouncing balls with gravity."""
        speed = self._effective_speed
        intensity = self._intensity
        color = self._color

        num_balls = 3
        # Initialize ball state on first call or LED count change
        if self._ball_positions is None or len(self._ball_positions) != num_balls:
            self._ball_positions = np.array([n * 0.3, n * 0.5, n * 0.8], dtype=np.float64)
            self._ball_velocities = np.array([0.0, 0.0, 0.0], dtype=np.float64)
            self._ball_last_t = t

        dt = min(t - self._ball_last_t, 0.1)  # cap delta to avoid explosion
        self._ball_last_t = t
        if dt <= 0:
            dt = 1.0 / 30

        gravity = 50.0 * intensity * speed
        damping = 0.85

        for i in range(num_balls):
            self._ball_velocities[i] += gravity * dt
            self._ball_positions[i] += self._ball_velocities[i] * dt
            # Bounce off bottom (index n-1)
            if self._ball_positions[i] >= n - 1:
                self._ball_positions[i] = n - 1
                self._ball_velocities[i] = -abs(self._ball_velocities[i]) * damping
                # Re-launch if nearly stopped
                if abs(self._ball_velocities[i]) < 2.0:
                    self._ball_velocities[i] = -30.0 * speed * (0.8 + 0.4 * (i / num_balls))
            # Bounce off top
            if self._ball_positions[i] < 0:
                self._ball_positions[i] = 0
                self._ball_velocities[i] = abs(self._ball_velocities[i]) * damping

        # Render balls with glow radius
        buf[:] = 0
        indices = self._s_arange
        r, g, b = color
        for i in range(num_balls):
            pos = self._ball_positions[i]
            dist = self._s_f32_a
            np.subtract(indices, pos, out=dist)
            np.abs(dist, out=dist)
            # Gaussian glow, radius ~3 LEDs
            np.multiply(dist, dist, out=self._s_f32_b)
            self._s_f32_b *= -0.5
            np.exp(self._s_f32_b, out=self._s_f32_b)
            # Hue offset per ball
            hue_shift = i / num_balls
            br = r * (1 - hue_shift * 0.5)
            bg = g * (0.5 + hue_shift * 0.5)
            bb = b * (0.3 + hue_shift * 0.7)
            for ch_idx, ch_val in enumerate((br, bg, bb)):
                np.multiply(self._s_f32_b, ch_val, out=self._s_f32_c)
                self._s_f32_a[:] = buf[:, ch_idx]
                self._s_f32_a += self._s_f32_c
                np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
                np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')

    # ── Fireworks ─────────────────────────────────────────────────────

    def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
        """Rockets launch and explode into colorful particle bursts."""
        speed = self._effective_speed
        intensity = self._intensity
        lut = self._palette_lut

        dt = 1.0 / max(self._fps, 1)

        # Launch rockets periodically
        launch_interval = max(0.3, 1.5 / max(intensity, 0.1))
        if t - self._fw_last_launch > launch_interval:
            self._fw_last_launch = t
            # Rocket: [position, velocity, palette_idx]
            target = 0.2 + np.random.random() * 0.5  # explode at 20-70% height
            rocket_speed = n * (0.8 + np.random.random() * 0.4) * speed
            self._fw_rockets.append([float(n - 1), -rocket_speed, target * n])

        # Update rockets
        new_rockets = []
        for rocket in self._fw_rockets:
            rocket[0] += rocket[1] * dt
            if rocket[0] <= rocket[2]:
                # Explode: create particles
                num_particles = int(8 + 8 * intensity)
                palette_idx = np.random.randint(0, 256)
                for _ in range(num_particles):
                    vel = (np.random.random() - 0.5) * n * 0.5 * speed
                    # [position, velocity, brightness, palette_idx]
                    self._fw_particles.append([rocket[0], vel, 1.0, palette_idx])
            else:
                new_rockets.append(rocket)
        self._fw_rockets = new_rockets

        # Update particles
        new_particles = []
        for p in self._fw_particles:
            p[0] += p[1] * dt
            p[1] *= 0.97  # drag
            p[2] -= dt * 1.5  # fade
            if p[2] > 0.02 and 0 <= p[0] < n:
                new_particles.append(p)
        self._fw_particles = new_particles

        # Cap active particles
        if len(self._fw_particles) > 200:
            self._fw_particles = self._fw_particles[-200:]

        # Render
        buf[:] = 0
        for p in self._fw_particles:
            pos, _vel, bright, pal_idx = p
            idx = int(pos)
            if 0 <= idx < n:
                color = lut[int(pal_idx) % 256]
                for ch in range(3):
                    val = int(buf[idx, ch] + color[ch] * bright)
                    buf[idx, ch] = min(255, val)
                # Spread to neighbors
                for offset in (-1, 1):
                    ni = idx + offset
                    if 0 <= ni < n:
                        for ch in range(3):
                            val = int(buf[ni, ch] + color[ch] * bright * 0.4)
                            buf[ni, ch] = min(255, val)

        # Render rockets as bright white dots
        for rocket in self._fw_rockets:
            idx = int(rocket[0])
            if 0 <= idx < n:
                buf[idx] = (255, 255, 255)

    # ── Sparkle Rain ──────────────────────────────────────────────────

    def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
        """Twinkling star field with smooth fade-in/fade-out."""
        speed = self._effective_speed
        intensity = self._intensity
        lut = self._palette_lut

        # Initialize/resize sparkle state
        if self._sparkle_state is None or len(self._sparkle_state) != n:
            self._sparkle_state = np.zeros(n, dtype=np.float32)

        dt = 1.0 / max(self._fps, 1)
        state = self._sparkle_state

        # Fade existing sparkles
        fade_rate = 1.5 * speed
        state -= fade_rate * dt
        np.clip(state, 0.0, 1.0, out=state)

        # Spawn new sparkles
        spawn_prob = 0.05 * intensity * speed
        rng = np.random.random(n)
        new_mask = (rng < spawn_prob) & (state < 0.1)
        state[new_mask] = 1.0

        # Map sparkle brightness to palette
        np.multiply(state, 255, out=self._s_f32_a)
        np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
        np.clip(self._s_i32, 0, 255, out=self._s_i32)
        self._s_f32_rgb[:] = lut[self._s_i32]
        # Apply brightness
        self._s_f32_rgb *= state[:, np.newaxis]
        np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
        np.copyto(buf, self._s_f32_rgb, casting='unsafe')

    # ── Lava Lamp ─────────────────────────────────────────────────────

    def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
        """Slow-moving colored blobs that merge and separate."""
        speed = self._effective_speed
        scale = self._scale
        lut = self._palette_lut

        # Use noise at very low frequency for blob movement
        np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)

        # Two blob layers at different speeds for organic movement
        self._s_f32_a += t * speed * 0.1
        layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()

        np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
        self._s_f32_a += t * speed * 0.07 + 100.0
        layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()

        # Combine: create blob-like shapes with soft edges
        combined = self._s_f32_a
        np.multiply(layer1, 0.6, out=combined)
        combined += layer2 * 0.4
        # Sharpen to create distinct blobs (sigmoid-like)
        combined -= 0.45
        combined *= 6.0
        # Soft clamp via tanh approximation: x / (1 + |x|)
        np.abs(combined, out=self._s_f32_b)
        self._s_f32_b += 1.0
        np.divide(combined, self._s_f32_b, out=combined)
        # Map from [-1,1] to [0,1]
        combined += 1.0
        combined *= 0.5
        np.clip(combined, 0.0, 1.0, out=combined)

        # Map to palette
        np.multiply(combined, 255, out=self._s_f32_b)
        np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
        np.clip(self._s_i32, 0, 255, out=self._s_i32)
        buf[:] = lut[self._s_i32]

    # ── Wave Interference ─────────────────────────────────────────────

    def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
        """Two counter-propagating sine waves creating interference patterns."""
        speed = self._effective_speed
        scale = self._scale
        lut = self._palette_lut

        # Wave parameters
        k = 2.0 * math.pi * scale / max(n, 1)  # wavenumber
        omega = speed * 2.0  # angular frequency

        # Wave 1: right-propagating
        indices = self._s_arange
        np.multiply(indices, k, out=self._s_f32_a)
        self._s_f32_a -= omega * t
        np.sin(self._s_f32_a, out=self._s_f32_a)

        # Wave 2: left-propagating at slightly different frequency
        np.multiply(indices, k * 1.1, out=self._s_f32_b)
        self._s_f32_b += omega * t * 0.9
        np.sin(self._s_f32_b, out=self._s_f32_b)

        # Interference: sum and normalize to [0, 255]
        self._s_f32_a += self._s_f32_b
        # Range is [-2, 2], map to [0, 255]
        self._s_f32_a += 2.0
        self._s_f32_a *= (255.0 / 4.0)
        np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
        np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
        buf[:] = lut[self._s_i32]
