//! breakcore — chaotic-IDM energy on a smooth, dark cybersigil.
//!
//! Premise → implementation map:
//!   §1 geometry : the 64 capsule points are **partitioned** into four
//!                 co-existing structures (no longer one wire):
//!                   · spine   — Lorenz/Rössler attractor ⇄ distorted
//!                               torus-knot, the connected backbone;
//!                   · ribs    — a spectral shell, 8 struts whose length +
//!                               girth track log-spectrum bands;
//!                   · debris  — transient capsule shrapnel spawned by
//!                               high/flux onsets, orbits then decays;
//!                   · spokes  — 3 harmonic nodes on the loudest chroma
//!                               pitch classes (key/chord changes show).
//!   §2 audio    : derived entirely from [`Bands`] — `audio.rs` already did
//!                 the FFT; this never touches it.
//!   §3 smoothing: every reactive scalar is a critically-damped [`Spring`];
//!                 audio sets the *target*, never the value directly.
//!   §4 render   : a hand-written wgpu raymarcher (`breakcore.wgsl`) — a
//!                 grouped SDF capsule chain unioned with a polynomial
//!                 smooth-min, accumulated as volumetric glow over black.
//!   §5 structure: a feature-vector classifier ([`Structure`]) — slow EMAs
//!                 of energy/brightness/bass/busyness + a chroma-novelty
//!                 term — sorts the track into [`Arch`] archetypes
//!                 (Ambient/Build/Drop/Breakdown/Groove), each mapping to a
//!                 distinct visual [`Regime`]. A build-up is a first-class
//!                 `tension` ramp that *integrates* rising brightness/density
//!                 while the bass is suppressed and *releases* on the drop.
//!
//! This is the one module that owns a hand-written wgpu pipeline; `post.rs`
//! and the other visualisers stay on nannou's validated renderer. It is *not*
//! a `Draw`-based `Visual`: the bin renders it through [`Breakcore::render`]
//! and presents/captures its target texture directly.
//!
//! Determinism: `Rng` and all integration advance only in [`Breakcore::update`]
//! (one call per frame, live and `--render` alike); the shader is a pure
//! function of the uniform block + hash(fragCoord, frame). So `--render` stays
//! bit-reproducible and there is no per-frame chaos.

use crate::audio::{Bands, CHROMA_N, SPEC_N};
use crate::viz::curve::{Rng, fbm};
use crate::viz::palette::{Palette, oklch};
use crate::viz::post::read_texture_rgba;
use nannou::prelude::*;
use nannou::wgpu;

/// Capsule control points. **MUST** equal the `array<vec4, N>` size in
/// `breakcore.wgsl`. Kept low: shader cost is O(pixels · march_steps · NP).
pub const NP: usize = 64;

// --- §1 partition of the 64 points into co-existing structures. The group
// bounds below are **coupled to `breakcore.wgsl`** exactly like `NP`: the
// shader's `map()` hard-codes `G_SPINE`/`G_R0`/`G_S1`. Change here ⇒ change
// there. Layout: [0,SPINE) connected backbone, then [SPINE,NP) is disjoint
// *pairs* (one capsule per pair) for ribs ▸ debris ▸ spokes.
const SPINE: usize = 34; //   0..34   connected attractor/knot chain
const RIBS0: usize = 34; //  34..50   8 spectral struts (pairs)
const RIBS1: usize = 50;
const DEB0: usize = 50; //  50..58   4 transient debris fragments (pairs)
const DEB1: usize = 58;
const SPK0: usize = 58; //  58..64   3 harmonic spokes (pairs)
const SPK1: usize = 64;
const N_RIBS: usize = (RIBS1 - RIBS0) / 2; // 8
const N_DEB: usize = (DEB1 - DEB0) / 2; // 4
const N_SPK: usize = (SPK1 - SPK0) / 2; // 3

/// UBO length in f32: 10 std140 rows (40) + NP·vec4.
const UBO_LEN: usize = 40 + 4 * NP;

/// Where a parked (inactive) capsule goes — far outside the bounding sphere
/// so its closest-approach glow is exactly zero (cheaper than a zero radius,
/// which would still draw the line). Stored flat since `Vec3::new` is non-const.
const PARK: f32 = 60.0;
const PARKED: [f32; 4] = [PARK, PARK, PARK, 0.001];

const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
const TAU: f32 = std::f32::consts::TAU;

fn smoothstep(t: f32) -> f32 {
    let t = t.clamp(0.0, 1.0);
    t * t * (3.0 - 2.0 * t)
}

/// Ease an angle toward a target along the *shortest* arc (no 2π flicker
/// when the hue wraps). `a` is the per-frame lerp fraction.
fn angle_to(cur: f32, target: f32, a: f32) -> f32 {
    let mut d = (target - cur) % TAU;
    if d > std::f32::consts::PI {
        d -= TAU;
    } else if d < -std::f32::consts::PI {
        d += TAU;
    }
    cur + d * a
}

/// Critically-damped spring (premise §3). `omega` is the natural frequency
/// (stiffness); damping is fixed at ζ=1 so it never overshoots — a kick
/// expands instantly then glides back with no ring.
#[derive(Clone, Copy, Default)]
struct Spring {
    x: f32,
    v: f32,
}

impl Spring {
    fn step(&mut self, target: f32, omega: f32, dt: f32) {
        // Semi-implicit Euler of  x'' = -ω²(x-target) - 2ω x'  (ζ = 1).
        let a = -(self.x - target) * omega * omega - 2.0 * omega * self.v;
        self.v += a * dt;
        self.x += self.v * dt;
    }
}

/// Which backbone geometry a section shows.
#[derive(Clone, Copy, PartialEq)]
enum Kind {
    Attractor,
    Knot,
}

/// Lorenz or Rössler — both chaotic, integrated by RK4.
#[derive(Clone, Copy)]
enum Attr {
    Lorenz { sigma: f32, rho: f32, beta: f32 },
    Rossler { a: f32, b: f32, c: f32 },
}

impl Attr {
    fn random(rng: &mut Rng) -> Self {
        if rng.chance(0.5) {
            Attr::Lorenz {
                sigma: 10.0,
                rho: rng.range(26.0, 32.0),
                beta: 8.0 / 3.0,
            }
        } else {
            Attr::Rossler {
                a: rng.range(0.1, 0.22),
                b: rng.range(0.1, 0.3),
                c: rng.range(4.5, 9.0),
            }
        }
    }

    fn deriv(&self, p: Vec3) -> Vec3 {
        match *self {
            Attr::Lorenz { sigma, rho, beta } => vec3(
                sigma * (p.y - p.x),
                p.x * (rho - p.z) - p.y,
                p.x * p.y - beta * p.z,
            ),
            Attr::Rossler { a, b, c } => {
                vec3(-p.y - p.z, p.x + a * p.y, b + p.z * (p.x - c))
            }
        }
    }

    fn rk4(&self, p: Vec3, h: f32) -> Vec3 {
        let k1 = self.deriv(p);
        let k2 = self.deriv(p + k1 * (h * 0.5));
        let k3 = self.deriv(p + k2 * (h * 0.5));
        let k4 = self.deriv(p + k3 * h);
        p + (k1 + k2 * 2.0 + k3 * 2.0 + k4) * (h / 6.0)
    }
}

/// One torus-knot config: coprime-ish (p,q) + turns, distorted by highs.
#[derive(Clone, Copy)]
struct Knot {
    p: f32,
    q: f32,
    turns: f32,
}

impl Knot {
    fn random(rng: &mut Rng) -> Self {
        Knot {
            p: (2 + rng.idx(6)) as f32,
            q: (1 + rng.idx(7)) as f32,
            turns: rng.range(2.0, 4.0),
        }
    }
    fn at(&self, u: f32) -> Vec3 {
        let th = TAU * self.turns * u;
        let r = (self.q * th).cos() + 2.0;
        vec3(
            r * (self.p * th).cos(),
            r * (self.p * th).sin(),
            -(self.q * th).sin(),
        )
    }
}

/// Normalise a point set to ~unit radius so framing stays readable whatever
/// the attractor/knot extent (premise: chaotic but never an unreadable mess).
fn normalize(pts: &mut [Vec3]) {
    let mut c = Vec3::ZERO;
    for p in pts.iter() {
        c += *p;
    }
    c /= pts.len().max(1) as f32;
    let mut m = 1e-6f32;
    for p in pts.iter() {
        m = m.max((*p - c).length());
    }
    let s = 0.92 / m;
    for p in pts.iter_mut() {
        *p = (*p - c) * s;
    }
}

// ---------------------------------------------------------------------------
// §5 musical-structure model
// ---------------------------------------------------------------------------

/// Coarse song-section archetype. The classifier never sees a label in the
/// audio; this is inferred from the slow feature vector in [`Structure`].
#[derive(Clone, Copy, PartialEq, Debug)]
enum Arch {
    Ambient,   // sparse / intro / pad
    Build,     // rising tension (riser, snare roll, filter open)
    Drop,      // post-build hit / dense break
    Breakdown, // energy collapsed after a loud section
    Groove,    // sustained mid-energy default
}

/// The per-archetype visual regime: spring/uniform *targets*, never values.
/// Audio still rides on top of these via the springs (premise §3).
#[derive(Clone, Copy)]
struct Regime {
    scale: f32,     // backbone normalize-scale target
    melt: f32,      // smooth-min fuse weight (0..1, scales melt_k)
    glow: f32,      // filament brightness base
    speed: f32,     // rotation + attractor integration multiplier
    tube: f32,      // base capsule radius
    rib: f32,       // spectral-shell presence 0..1
    deb: f32,       // debris presence 0..1
    heat: f32,      // colour-warmth / energy push base 0..1
    warpd: f32,     // spine spectral-displacement amount
    ca: f32,        // chromatic-aberration multiplier
    fade: f32,      // feedback-fade multiplier (smaller ⇒ longer trail)
    prefer_knot: bool, // bias the next restructure's backbone kind
}

impl Arch {
    fn regime(self) -> Regime {
        match self {
            // Wide, slow, thin, dim. Knot (ordered) backbone, shell barely on.
            Arch::Ambient => Regime {
                scale: 0.95,
                melt: 0.3,
                glow: 0.40,
                speed: 0.55,
                tube: 0.013,
                rib: 0.10,
                deb: 0.0,
                heat: 0.05,
                warpd: 0.04,
                ca: 0.8,
                fade: 1.0,
                prefer_knot: true,
            },
            // Contracting toward a dense core; everything ramps with tension
            // (see Breakcore::update — these are the *base* before tension).
            Arch::Build => Regime {
                scale: 0.78,
                melt: 0.7,
                glow: 0.60,
                speed: 0.9,
                tube: 0.016,
                rib: 0.55,
                deb: 0.25,
                heat: 0.45,
                warpd: 0.12,
                ca: 1.2,
                fade: 0.7,
                prefer_knot: false,
            },
            // The hit: explosive, chaotic backbone, debris + shell wide open.
            Arch::Drop => Regime {
                scale: 0.92,
                melt: 0.55,
                glow: 0.95,
                speed: 1.35,
                tube: 0.020,
                rib: 0.85,
                deb: 1.0,
                heat: 0.85,
                warpd: 0.16,
                ca: 1.4,
                fade: 0.6,
                prefer_knot: false,
            },
            // Hollowed out after a drop: medium, soft, cool, slow.
            Arch::Breakdown => Regime {
                scale: 0.85,
                melt: 0.45,
                glow: 0.50,
                speed: 0.7,
                tube: 0.015,
                rib: 0.30,
                deb: 0.10,
                heat: 0.15,
                warpd: 0.07,
                ca: 0.9,
                fade: 0.85,
                prefer_knot: true,
            },
            // Balanced sustained default.
            Arch::Groove => Regime {
                scale: 0.82,
                melt: 0.5,
                glow: 0.65,
                speed: 1.0,
                tube: 0.016,
                rib: 0.55,
                deb: 0.5,
                heat: 0.35,
                warpd: 0.10,
                ca: 1.0,
                fade: 0.9,
                prefer_knot: false,
            },
        }
    }
}

/// Slow feature-vector model of where we are in the track. All inputs are
/// already AGC-normalised 0..1 in [`Bands`]; this just adds time structure.
struct Structure {
    e: f32,   // energy   (loud)  — fast EMA
    br: f32,  // bright   (centroid)
    ba: f32,  // bass     (low)
    bu: f32,  // busy     (flux + per-band onsets) — onset density
    e_l: f32, // long refs (~8 s) for relative comparison
    ba_l: f32,
    bu_l: f32,
    ch: [f32; CHROMA_N], // chroma EMA → harmonic-novelty distance
    prev_br: f32,
    prev_bu: f32,
    tension: f32,  // 0..1 build-up ramp (premise §5 / axis B)
    novelty: f32,  // 0..1 decaying harmonic-change pulse
    release: f32,  // 0..1 decaying drop-release pulse
    arch: Arch,
    cand: Arch,    // hysteresis: a candidate must dwell before it commits
    cand_t: f32,
}

impl Structure {
    fn new() -> Self {
        Structure {
            e: 0.0,
            br: 0.0,
            ba: 0.0,
            bu: 0.0,
            e_l: 0.0,
            ba_l: 0.0,
            bu_l: 0.0,
            ch: [0.0; CHROMA_N],
            prev_br: 0.0,
            prev_bu: 0.0,
            tension: 0.0,
            novelty: 0.0,
            release: 0.0,
            arch: Arch::Ambient,
            cand: Arch::Ambient,
            cand_t: 0.0,
        }
    }

    /// Advance the model one frame. Returns `true` on a committed archetype
    /// change (the event the geometry restructures on).
    fn update(&mut self, b: &Bands, dt: f32) -> bool {
        let inv = 1.0 / dt.max(1e-4);
        let a_fast = 1.0 - (-dt / 1.2).exp();
        let a_bass = 1.0 - (-dt / 0.8).exp();
        let a_long = 1.0 - (-dt / 8.0).exp();

        let busy_in =
            (b.flux + 0.5 * (b.low_on + b.mid_on + b.high_on)).min(1.5);
        self.e += (b.loud - self.e) * a_fast;
        self.br += (b.centroid - self.br) * a_fast;
        self.ba += (b.low - self.ba) * a_bass;
        self.bu += (busy_in - self.bu) * a_fast;
        self.e_l += (b.loud - self.e_l) * a_long;
        self.ba_l += (b.low - self.ba_l) * a_long;
        self.bu_l += (busy_in - self.bu_l) * a_long;

        // Harmonic novelty: L1 drift of the chroma vector vs its slow EMA.
        // A chord/key change spikes this even when loudness is flat.
        let a_ch = 1.0 - (-dt / 4.0).exp();
        let mut drift = 0.0;
        for i in 0..CHROMA_N {
            drift += (b.chroma[i] - self.ch[i]).abs();
            self.ch[i] += (b.chroma[i] - self.ch[i]) * a_ch;
        }
        self.novelty = (self.novelty - dt / 0.6)
            .max(0.0)
            .max((drift / CHROMA_N as f32 * 3.0).min(1.0));

        // §B tension: integrate rising brightness + busyness, but only while
        // the bass is *suppressed* relative to its long ref (the kick drops
        // out under a build-up). Bass back ⇒ tension bleeds off fast.
        let d_br = (self.br - self.prev_br) * inv;
        let d_bu = (self.bu - self.prev_bu) * inv;
        self.prev_br = self.br;
        self.prev_bu = self.bu;
        let bass_supp = (1.0 - self.ba / (self.ba_l + 1e-3)).clamp(0.0, 1.0);
        let rise = (d_br.max(0.0) * 1.4 + d_bu.max(0.0) * 1.0) * (0.35 + 0.65 * bass_supp);
        self.tension = (self.tension + (rise * 2.6 - 0.22) * dt).clamp(0.0, 1.0);
        if self.ba > 0.85 * self.ba_l {
            self.tension = (self.tension - dt * 1.6).max(0.0);
        }

        // Release: a primed build collapsing into a bass+flux hit = the drop.
        let primed = self.tension > 0.5;
        let hit = b.low_on > 0.5 || (self.ba - self.ba_l) > 0.18;
        let mut drop_now = false;
        if primed && hit && b.flux > 0.4 {
            self.release = 1.0;
            self.tension = 0.0;
            drop_now = true;
        }
        self.release = (self.release - dt / 0.5).max(0.0);

        // Classify. Drop on release is instant; everything else must dwell to
        // beat hysteresis so a section is one change, not a stutter.
        let cand = if drop_now {
            Arch::Drop
        } else if self.tension > 0.45 {
            Arch::Build
        } else if self.e < 0.32 && self.bu < 0.28 {
            Arch::Ambient
        } else if self.e > 0.60 && self.ba > 0.52 && self.bu > 0.48 {
            Arch::Drop
        } else if self.e_l > 0.30 && self.e < 0.52 * self.e_l {
            Arch::Breakdown
        } else {
            Arch::Groove
        };

        if cand == self.cand {
            self.cand_t += dt;
        } else {
            self.cand = cand;
            self.cand_t = 0.0;
        }
        let dwell = if drop_now { 0.0 } else { 0.55 };
        if self.cand != self.arch && self.cand_t >= dwell {
            self.arch = self.cand;
            return true;
        }
        false
    }
}

/// One transient debris fragment (premise §1, breakcore "shrapnel").
#[derive(Clone, Copy, Default)]
struct Frag {
    p: Vec3,
    v: Vec3,
    life: f32, // 1 → 0
}

pub struct Breakcore {
    pub seed: u64,
    rng: Rng,

    attr: Attr,
    knot: Knot,
    trail: [Vec3; NP], // rolling attractor trajectory (oldest..newest)
    head: Vec3,        // current attractor state

    from: Kind,
    to: Kind,
    morph: f32, // 0..1 from→to (1 = settled)
    cooldown: f32,
    idle: f32,
    trans: f32, // 0..1 section-change pulse (drives the swap burst)

    st: Structure,
    deb: [Frag; N_DEB],
    deb_gate: f32, // debris-spawn cooldown
    rib_phase: f32,

    sp_scale: Spring,
    sp_tube: Spring,
    sp_dist: Spring,
    sp_glow: Spring,
    sp_focal: Spring, // lens focal length — tension widens the FOV
    sp_dolly: Spring, // beat/kick camera dolly-punch
    jolt: f32,        // decaying snare/hat rotational kick
    shock: f32,       // decaying broadband-onset radial pulse
    hue_b: f32,       // smoothed base hue (rad) — colour inertia
    hue_a: f32,       // smoothed accent hue (rad)
    yaw: f32,
    pitch: f32,
    roll: f32,
    t: f32,
    frame: u32,

    b: Bands,
    gpu: Gpu,
}

impl Breakcore {
    pub fn new(seed: u64, device: &wgpu::Device) -> Self {
        let mut rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
        let attr = Attr::random(&mut rng);
        let knot = Knot::random(&mut rng);
        Breakcore {
            seed,
            rng,
            attr,
            knot,
            trail: [Vec3::ZERO; NP],
            head: vec3(0.1, 0.0, 0.0),
            from: Kind::Knot,
            to: Kind::Knot,
            morph: 1.0,
            cooldown: 0.0,
            idle: 0.0,
            trans: 0.0,
            st: Structure::new(),
            deb: [Frag::default(); N_DEB],
            deb_gate: 0.0,
            rib_phase: 0.0,
            sp_scale: Spring { x: 1.0, v: 0.0 },
            sp_tube: Spring { x: 0.016, v: 0.0 },
            sp_dist: Spring { x: 3.2, v: 0.0 },
            sp_glow: Spring { x: 0.6, v: 0.0 },
            sp_focal: Spring { x: 1.7, v: 0.0 },
            sp_dolly: Spring { x: 0.0, v: 0.0 },
            jolt: 0.0,
            shock: 0.0,
            hue_b: 2.0,
            hue_a: 4.0,
            yaw: 0.0,
            pitch: 0.0,
            roll: 0.0,
            t: 0.0,
            frame: 0,
            b: Bands::default(),
            gpu: Gpu::new(device),
        }
    }

    pub fn reseed(&mut self, seed: u64) {
        self.seed = seed;
        self.rng = Rng::new(seed ^ 0xB17E_C0DE_B17E_C0DE);
        self.attr = Attr::random(&mut self.rng);
        self.knot = Knot::random(&mut self.rng);
        self.from = Kind::Knot;
        self.to = Kind::Knot;
        self.morph = 1.0;
        self.head = vec3(0.1, 0.0, 0.0);
        self.trail = [Vec3::ZERO; NP];
        self.idle = 0.0;
        self.deb = [Frag::default(); N_DEB];
        self.st = Structure::new();
    }

    pub fn point_count(&self) -> usize {
        NP
    }

    /// Begin a section change: pick the new backbone kind from the archetype's
    /// bias, reseed both configs, restart the morph, fire the swap burst.
    fn restructure(&mut self, prefer_knot: bool) {
        self.from = self.to;
        self.to = if prefer_knot {
            Kind::Knot
        } else {
            Kind::Attractor
        };
        self.attr = Attr::random(&mut self.rng);
        self.knot = Knot::random(&mut self.rng);
        self.morph = 0.0;
        self.cooldown = 1.4;
        self.idle = 0.0;
        self.trans = 1.0;
    }

    fn update_debris(&mut self, deb_presence: f32, dt: f32) {
        self.deb_gate = (self.deb_gate - dt).max(0.0);
        let trigger = self.b.high_on > 0.45 || self.b.flux > 0.5;
        if trigger && self.deb_gate <= 0.0 && deb_presence > 0.15 {
            // Replace the most-decayed slot. Spawn on a random unit direction
            // near the backbone, fired outward.
            let mut slot = 0;
            for i in 1..N_DEB {
                if self.deb[i].life < self.deb[slot].life {
                    slot = i;
                }
            }
            let dir = vec3(
                self.rng.range(-1.0, 1.0),
                self.rng.range(-1.0, 1.0),
                self.rng.range(-1.0, 1.0),
            )
            .normalize_or_zero();
            let spd = self.rng.range(0.5, 1.3) * (0.4 + 0.6 * deb_presence);
            self.deb[slot] = Frag {
                p: dir * self.rng.range(0.15, 0.45),
                v: dir * spd,
                life: 1.0,
            };
            self.deb_gate = 0.055;
        }
        for f in self.deb.iter_mut() {
            if f.life <= 0.0 {
                continue;
            }
            f.p += f.v * dt;
            f.v *= 1.0 - (2.4 * dt).min(0.9); // air drag → quick settle
            f.life = (f.life - dt / 0.5).max(0.0);
        }
    }

    pub fn update(&mut self, b: &Bands, dt: f32) {
        let dt = dt.clamp(0.0, 0.05);
        self.t += dt;
        self.frame = self.frame.wrapping_add(1);
        self.b = *b;

        // §5 structure model first — the regime drives every target below.
        let arch_changed = self.st.update(b, dt);
        let rg = self.st.arch.regime();
        let tn = self.st.tension;
        let rel = self.st.release;

        // §3 springs — regime sets the base, audio + tension ride on top.
        // Build-up contracts the backbone toward a dense core; release kicks
        // it back out (spring overshoot makes the drop *punch*).
        let scale_t = rg.scale * (1.0 - 0.42 * tn) + 0.30 * b.low + 0.25 * b.low_on
            + 0.85 * rel;
        self.sp_scale.step(scale_t, 14.0, dt);
        self.sp_tube
            .step(rg.tube + 0.05 * b.mid + 0.02 * b.mid_on, 11.0, dt);
        self.sp_dist.step(3.4 - 0.9 * b.low, 6.0, dt);
        self.sp_glow.step(
            rg.glow + 0.45 * b.loud + 0.4 * b.flux + 0.35 * tn,
            9.0,
            dt,
        );

        // Music-locked rotation. No constant baseline — true silence leaves
        // the field still; every term is audio · regime · tension. A snare/hat
        // onset adds a quick rotational *jolt* (decays, so it reads as a kick).
        let sp = rg.speed * (1.0 + 0.7 * tn);
        self.jolt = (self.jolt * 0.88).max(0.6 * b.high_on + 0.4 * b.mid_on);
        self.yaw += (0.7 * b.mid + 0.9 * self.jolt) * sp * dt;
        self.pitch += 0.4 * b.low * sp * dt;
        self.roll += (0.35 * b.high + 1.4 * self.jolt) * sp * dt;
        self.rib_phase += 0.5 * b.mid * dt;

        // Beat/kick camera dolly-punch + tension FOV breathing. The lens
        // widens through a build then snaps back on the drop.
        self.sp_dolly
            .step(0.55 * b.low_on + 0.45 * b.beat, 20.0, dt);
        self.sp_focal
            .step((1.70 + 0.55 * tn - 0.15 * b.loud).clamp(1.25, 2.40), 6.0, dt);

        // Broadband-onset radial shock: the whole sigil pulses out per big
        // hit, distinct from the section restructure. Decays in ~0.4 s.
        self.shock = (self.shock - dt / 0.4).max(0.0);
        if b.flux > 0.55 {
            self.shock = 1.0;
        }

        // Colour inertia: hues ease toward their audio targets along the
        // shortest arc (no per-frame flicker). Dual-tone — the accent offset
        // depends on the archetype: complementary on a Drop (max contrast),
        // analogous when calm (Ambient/Breakdown).
        let base_t = (4.4 - b.centroid * 3.1).rem_euclid(TAU);
        let (mut dom, mut dv) = (0usize, 0.0f32);
        for (i, &cc) in b.chroma.iter().enumerate() {
            if cc > dv {
                dv = cc;
                dom = i;
            }
        }
        let offs = match self.st.arch {
            Arch::Drop => std::f32::consts::PI,
            Arch::Ambient | Arch::Breakdown => 0.6,
            Arch::Build => 2.2,
            Arch::Groove => 1.7,
        };
        let acc_t = base_t + offs + dom as f32 / CHROMA_N as f32 * TAU * 0.5;
        let ha = 1.0 - (-dt / 0.4).exp();
        self.hue_b = angle_to(self.hue_b, base_t, ha);
        self.hue_a = angle_to(self.hue_a, acc_t, ha);

        // §1 attractor: RK4 substeps; integration speed tracks sub/bass and
        // the regime/tension so the thread surges into the drop. No baseline:
        // a silent passage freezes the trajectory (intended stillness).
        let speed = (1.4 * b.low + 0.5 * b.low_on) * sp;
        let h = (speed * dt).clamp(0.0, 0.03);
        for _ in 0..6 {
            self.head = self.attr.rk4(self.head, h);
            if !self.head.is_finite() {
                self.head = vec3(0.1, 0.0, 0.0);
            }
        }
        self.trail.copy_within(1..NP, 0);
        self.trail[NP - 1] = self.head;

        self.update_debris(rg.deb, dt);

        if self.morph < 1.0 {
            self.morph = (self.morph + dt / 1.8).min(1.0);
            if self.morph >= 1.0 {
                self.from = self.to;
            }
        }
        self.trans = (self.trans - dt / 0.5).max(0.0);
        self.cooldown = (self.cooldown - dt).max(0.0);
        self.idle += dt;

        // A swap fires on a real structure event — an archetype commit or a
        // strong harmonic novelty — gated by morph/cooldown. The idle
        // fallback is a rare safety so a flat drone still eventually evolves.
        let event = arch_changed || self.st.novelty > 0.75;
        if self.morph >= 1.0 && self.cooldown <= 0.0 && (event || self.idle > 20.0) {
            self.restructure(rg.prefer_knot);
        }
    }

    /// Shared world scale: the backbone spring plus the broadband radial
    /// shock, so every structure pulses out together on a big hit and the
    /// bounding sphere (which uses this too) never clips them.
    fn world_scale(&self) -> f32 {
        self.sp_scale.x.clamp(0.4, 2.4) * (1.0 + 0.12 * self.shock)
    }

    /// Sample the backbone into the spine slots, blending from→to with the
    /// morph and displacing each point along a noise normal by its spectrum
    /// band (premise §1 spine + axis D). A beat-phase gaussian "kick-wave"
    /// travels root→tip each beat, fattening the wire as it passes.
    fn build_spine(&self, out: &mut [[f32; 4]; NP]) {
        let pick_attr = {
            let mut v = [Vec3::ZERO; SPINE];
            for (i, slot) in v.iter_mut().enumerate() {
                let j = i * (NP - 1) / (SPINE - 1);
                *slot = self.trail[j];
            }
            normalize(&mut v);
            v
        };
        let pick_knot = {
            let mut v = [Vec3::ZERO; SPINE];
            for (i, slot) in v.iter_mut().enumerate() {
                let u = i as f32 / (SPINE - 1) as f32;
                *slot = self.knot.at(u);
            }
            normalize(&mut v);
            v
        };
        let sel = |k: Kind| match k {
            Kind::Attractor => &pick_attr,
            Kind::Knot => &pick_knot,
        };
        let e = smoothstep(self.morph);
        let from = sel(self.from);
        let to = sel(self.to);

        let rg = self.st.arch.regime();
        let warpd = rg.warpd + 0.10 * self.st.tension;
        let scale = self.world_scale();
        let s = self.seed as u32;
        let bp = self.b.beat_phase;
        let beat_amp = 0.4 + 0.6 * self.b.beat;

        let mut base = [Vec3::ZERO; SPINE];
        for i in 0..SPINE {
            base[i] = from[i] + (to[i] - from[i]) * e;
        }
        for i in 0..SPINE {
            // Tangent from neighbours → a stable normal for displacement.
            let prev = base[i.saturating_sub(1)];
            let next = base[(i + 1).min(SPINE - 1)];
            let tan = (next - prev).normalize_or_zero();
            let u = i as f32 / SPINE as f32;
            let nz = vec3(
                fbm(vec2(u * 19.0, 1.0), s),
                fbm(vec2(u * 19.0, 5.0), s ^ 0x9E37),
                fbm(vec2(u * 19.0, 9.0), s ^ 0x85EB),
            );
            let mut nrm = tan.cross(nz);
            if nrm.length_squared() < 1e-6 {
                nrm = vec3(0.0, 1.0, 0.0);
            }
            let nrm = nrm.normalize();
            let band = self.b.spec[(i * SPEC_N) / SPINE];
            // Kick-wave: a narrow gaussian centred at u == beat_phase that
            // rides from root to tip across the beat, then snaps back.
            let dw = u - bp;
            let kw = (-(dw * dw) * 60.0).exp() * beat_amp;
            let disp = warpd * (0.05 + 0.55 * band + 0.4 * self.b.high_on) + 0.05 * kw;
            let p = (base[i] + nrm * disp) * scale;
            let r = (self.sp_tube.x * (0.4 + 0.7 * band) * (1.0 + 0.8 * kw))
                .clamp(0.0015, 0.0075);
            out[i] = [p.x, p.y, p.z, r];
        }
    }

    /// 8 spectral struts forming a shell around the backbone. Each strut's
    /// length + girth tracks a log-spectrum band; a calm band collapses the
    /// strut and parks it (invisible).
    fn build_ribs(&self, out: &mut [[f32; 4]; NP]) {
        let rg = self.st.arch.regime();
        let scale = self.world_scale();
        for k in 0..N_RIBS {
            let band = self.b.spec[(k * SPEC_N) / N_RIBS];
            let pres = rg.rib * (0.15 + 0.95 * band);
            let i = RIBS0 + 2 * k;
            if pres < 0.04 {
                out[i] = PARKED;
                out[i + 1] = PARKED;
                continue;
            }
            let ang = k as f32 / N_RIBS as f32 * TAU + self.rib_phase;
            let el = (k as f32 / N_RIBS as f32 - 0.5) * std::f32::consts::PI * 0.7;
            let dir = vec3(el.cos() * ang.cos(), el.sin(), el.cos() * ang.sin());
            let inner = dir * (0.30 * scale);
            let outer = dir * ((0.30 + 0.70 * band * rg.rib) * scale);
            let r = (self.sp_tube.x * (0.3 + 1.3 * band) * pres).clamp(0.0015, 0.007);
            out[i] = [inner.x, inner.y, inner.z, r];
            out[i + 1] = [outer.x, outer.y, outer.z, r];
        }
    }

    /// Transient debris fragments — per-hit punch.
    fn build_debris(&self, out: &mut [[f32; 4]; NP]) {
        let scale = self.world_scale();
        for k in 0..N_DEB {
            let f = self.deb[k];
            let i = DEB0 + 2 * k;
            if f.life <= 0.0 {
                out[i] = PARKED;
                out[i + 1] = PARKED;
                continue;
            }
            let a = f.p * scale;
            let b = (f.p + f.v * 0.06) * scale;
            let r = (self.sp_tube.x * 1.4 * f.life).clamp(0.0015, 0.008);
            out[i] = [a.x, a.y, a.z, r];
            out[i + 1] = [b.x, b.y, b.z, r];
        }
    }

    /// 3 harmonic spokes on the loudest chroma pitch classes — key/chord
    /// changes become visible structure, not just an accent-hue shift.
    fn build_spokes(&self, out: &mut [[f32; 4]; NP]) {
        let scale = self.world_scale();
        // Indices of the top-N_SPK chroma bins.
        let mut order: [usize; CHROMA_N] = [0; CHROMA_N];
        for (i, o) in order.iter_mut().enumerate() {
            *o = i;
        }
        order.sort_by(|&x, &y| {
            self.b.chroma[y]
                .partial_cmp(&self.b.chroma[x])
                .unwrap_or(std::cmp::Ordering::Equal)
        });
        for k in 0..N_SPK {
            let pc = order[k];
            let cv = self.b.chroma[pc];
            let i = SPK0 + 2 * k;
            if cv < 0.12 {
                out[i] = PARKED;
                out[i + 1] = PARKED;
                continue;
            }
            let ang = pc as f32 / CHROMA_N as f32 * TAU;
            // A separate tilted plane from the ribs so spokes read distinctly.
            let dir = vec3(ang.cos(), 0.35 * (ang * 2.0).sin(), ang.sin())
                .normalize_or_zero();
            let inner = dir * (0.16 * scale);
            let outer = dir * ((0.16 + 0.62 * cv) * scale);
            let r = (self.sp_tube.x * (0.35 + 1.1 * cv)).clamp(0.0015, 0.0075);
            out[i] = [inner.x, inner.y, inner.z, r];
            out[i + 1] = [outer.x, outer.y, outer.z, r];
        }
    }

    fn build_points(&self) -> [[f32; 4]; NP] {
        let mut out = [[0.0f32; 4]; NP];
        self.build_spine(&mut out);
        self.build_ribs(&mut out);
        self.build_debris(&mut out);
        self.build_spokes(&mut out);
        out
    }

    /// Render this frame's raymarch into the target and return it. Mirrors the
    /// other modes' tunables: `scale`/`warp` come from the live gain keys,
    /// `fade` is the phosphor persistence, `ca_px` the aberration amount.
    /// Build-up `tension`/`release` modulate fade + CA internally so the bin
    /// needs no extra plumbing.
    #[allow(clippy::too_many_arguments)]
    pub fn render(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        pal: &Palette,
        scale: f32,
        warp: f32,
        feedback: bool,
        fade: f32,
        ca_px: f32,
        drive: f32,
    ) -> &wgpu::Texture {
        let pts = self.build_points();
        let rg = self.st.arch.regime();
        let tn = self.st.tension;
        let rel = self.st.release;
        // `drive` is the expressive-effect master: it scales the *screen-space
        // drama* (heat / glitch / grain / CA swing / swirl / scanline) but
        // never the geometry, springs or section model, so detection and
        // framing stay stable while taste is tunable per track.
        let dr = drive.clamp(0.0, 3.0);
        let heat = ((rg.heat + 0.7 * tn + 0.5 * rel) * dr).clamp(0.0, 1.0);

        // Colour from the inertia-smoothed hues (set in `update`). Tonal
        // material (low flatness) stays lush; noisy/break material desaturates
        // a touch so it reads harsher. Lightness rides loudness + tension.
        let lo = self.b.loud;
        let base = oklch(
            (0.55 + 0.30 * lo + 0.12 * tn).min(0.95),
            0.10 + 0.06 * lo,
            self.hue_b,
        );
        let sat = (0.13 + 0.10 * (lo * 0.5 + self.b.mid * 0.4))
            * (1.0 - 0.30 * self.b.flatness);
        let acc = oklch((0.62 + 0.28 * lo).min(0.97), sat, self.hue_a);

        let mut u = [0.0f32; UBO_LEN];
        // row0 cam
        u[0] = self.yaw;
        u[1] = self.pitch;
        u[2] = self.roll;
        // Dolly-punch pulls the camera in on the kick (clamped so it never
        // crosses the geometry).
        let cam_d = (self.sp_dist.x - 0.7 * self.sp_dolly.x).clamp(2.0, 6.0);
        u[3] = cam_d * (1.0 + 0.05 * (1.0 - scale));
        // row1 scale,tube,glow,ca
        u[4] = scale;
        u[5] = self.sp_tube.x;
        u[6] = self.sp_glow.x.clamp(0.40, 1.2);
        // build-up creeps the aberration; release spikes it (drive scales
        // the swing, not the user's base `ca`).
        u[7] = ca_px * rg.ca * (1.0 + (0.7 * tn + 2.0 * rel) * dr);
        // row2 base.rgb, fade
        u[8] = base[0];
        u[9] = base[1];
        u[10] = base[2];
        // longer trail under tension (smaller fade ⇒ slower decay).
        u[11] = (fade * rg.fade * (1.0 - 0.4 * tn)).clamp(0.02, 1.0);
        // row3 accent.rgb, flash
        u[12] = acc[0];
        u[13] = acc[1];
        u[14] = acc[2];
        u[15] = pal.flash;
        // row4 res, frame, n_pts, time
        u[16] = Gpu::RES as f32;
        u[17] = (self.frame & 0xffff) as f32;
        u[18] = NP as f32;
        u[19] = self.t;
        // row5 march_steps, melt_k, feedback_on, world_r
        // Steps are also hard-capped at 40 in the shader; keep this modest —
        // cost is O(pixels · steps · NP) and a runaway here is a GPU hang.
        u[20] = (22.0 + 9.0 * warp.min(2.0)).clamp(16.0, 40.0);
        // Tension fuses the folds (higher melt_k) so a build melts to a core.
        u[21] = (0.004 + 0.008 * self.b.loud + 0.010 * tn * rg.melt).clamp(0.003, 0.018);
        u[22] = if feedback && self.gpu.primed { 1.0 } else { 0.0 };
        // bounding-sphere radius: covers spine (0.92) + ribs/spokes/debris
        // (≤~1.05·scale) + tube, and grows with the shock so a pulse never
        // clips. Parked capsules sit far outside and contribute zero glow.
        u[23] = 1.05 * self.world_scale() + 0.18;
        // row6 trans_pulse, glitch, scan, grain
        u[24] = self.trans.max(rel);
        // continuous slice glitch from highs/flux + harmonic novelty.
        u[25] = ((0.06 * self.b.high + 0.12 * self.b.flux + 0.5 * self.st.novelty) * dr)
            .clamp(0.0, 0.30);
        // CRT scanline depth: a faint floor that deepens with loudness and
        // build-up tension — the "display" powers up with the track.
        u[26] = (0.02 + (0.07 * self.b.loud + 0.05 * tn) * dr).clamp(0.0, 0.14);
        // Film-grain amplitude: rides broadband flux, spikes on release.
        u[27] = (0.003 + (0.020 * self.b.flux + 0.010 * rel) * dr).clamp(0.0, 0.030);
        // row7 heat, tension, release, focal
        u[28] = heat;
        u[29] = tn;
        u[30] = rel;
        u[31] = self.sp_focal.x.clamp(1.20, 2.50);
        // row8 high_on, flatness, beat_phase, fog
        u[32] = self.b.high_on;
        u[33] = self.b.flatness;
        u[34] = self.b.beat_phase;
        // hazier through a build, snaps clear on the drop.
        u[35] = (0.35 + 0.45 * self.b.loud + 0.20 * tn).clamp(0.30, 0.85);
        // row9 swirl_zoom, swirl_rot, bg_glow, beat
        u[36] = (0.004 + (0.018 * self.b.loud + 0.020 * self.shock) * dr).clamp(0.0, 0.05);
        u[37] = ((0.006 * self.b.mid + 0.020 * tn) * dr).clamp(0.0, 0.05);
        u[38] = (0.15 + 0.70 * self.b.loud).clamp(0.0, 1.0);
        u[39] = self.b.beat;
        // points (after 10 std140 rows = 40 f32)
        for (i, p) in pts.iter().enumerate() {
            let o = 40 + 4 * i;
            u[o] = p[0];
            u[o + 1] = p[1];
            u[o + 2] = p[2];
            u[o + 3] = p[3];
        }

        self.gpu.render(device, queue, &u)
    }

    pub fn current(&self) -> &wgpu::Texture {
        self.gpu.current()
    }

    pub fn capture_raw(
        &self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
    ) -> anyhow::Result<Vec<u8>> {
        read_texture_rgba(device, queue, self.gpu.current())
    }
}

// ---------------------------------------------------------------------------
// Isolated wgpu raymarch pipeline. The deliberate exception to the codebase's
// "no hand-written wgpu pipelines" rule; everything risky is contained here.
// ---------------------------------------------------------------------------

struct Gpu {
    pipeline: wgpu::RenderPipeline,
    ubo: wgpu::Buffer,
    tex: [wgpu::Texture; 2],
    view: [wgpu::TextureViewHandle; 2],
    bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
    cur: usize,                 // index last written / presented
    primed: bool,
}

fn as_bytes(v: &[f32]) -> &[u8] {
    // A flat f32 slice has no padding; reinterpret as bytes for write_buffer.
    unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
}

impl Gpu {
    const RES: u32 = crate::viz::post::RES;

    fn new(device: &wgpu::Device) -> Self {
        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
            label: Some("breakcore-shader"),
            source: wgpu::ShaderSource::Wgsl(include_str!("breakcore.wgsl").into()),
        });

        let mk = || {
            wgpu::TextureBuilder::new()
                .size([Self::RES, Self::RES])
                .format(FMT)
                .usage(
                    wgpu::TextureUsages::RENDER_ATTACHMENT
                        | wgpu::TextureUsages::TEXTURE_BINDING
                        | wgpu::TextureUsages::COPY_SRC,
                )
                .build(device)
        };
        let tex = [mk(), mk()];
        let view = [
            tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
            tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
        ];

        // Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
        let bgl = wgpu::BindGroupLayoutBuilder::new()
            .uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
            .texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
            .sampler(wgpu::ShaderStages::FRAGMENT, true)
            .build(device);

        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
            label: Some("breakcore-pl"),
            bind_group_layouts: &[&bgl],
            push_constant_ranges: &[],
        });

        // Fullscreen triangle: no vertex buffers; default triangle-list.
        let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
            .vertex_entry_point("vs_main")
            .fragment_shader(&shader)
            .fragment_entry_point("fs_main")
            .color_format(FMT)
            .build(device);

        let ubo = device.create_buffer(&wgpu::BufferDescriptor {
            label: Some("breakcore-ubo"),
            size: (UBO_LEN * 4) as u64,
            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
            mapped_at_creation: false,
        });
        let sampler = wgpu::SamplerBuilder::new()
            .address_mode(wgpu::AddressMode::ClampToEdge)
            .mag_filter(wgpu::FilterMode::Linear)
            .min_filter(wgpu::FilterMode::Linear)
            .mipmap_filter(wgpu::FilterMode::Nearest)
            .build(device);

        let mk_bind = |w: usize| {
            wgpu::BindGroupBuilder::new()
                .binding(ubo.as_entire_binding())
                .texture_view(&view[1 - w])
                .sampler(&sampler)
                .build(device, &bgl)
        };
        let bind = [mk_bind(0), mk_bind(1)];

        Gpu {
            pipeline,
            ubo,
            tex,
            view,
            bind,
            cur: 0,
            primed: false,
        }
    }

    fn render(
        &mut self,
        device: &wgpu::Device,
        queue: &wgpu::Queue,
        ubo: &[f32; UBO_LEN],
    ) -> &wgpu::Texture {
        let w = 1 - self.cur; // write target; sample the last-written (self.cur)
        queue.write_buffer(&self.ubo, 0, as_bytes(ubo));

        let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
            label: Some("breakcore-enc"),
        });
        {
            let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
                label: Some("breakcore-pass"),
                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                    view: &self.view[w],
                    resolve_target: None,
                    ops: wgpu::Operations {
                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                        store: true,
                    },
                })],
                depth_stencil_attachment: None,
            });
            rp.set_pipeline(&self.pipeline);
            rp.set_bind_group(0, &self.bind[w], &[]);
            rp.draw(0..3, 0..1);
        }
        queue.submit(Some(enc.finish()));

        self.cur = w;
        self.primed = true;
        &self.tex[self.cur]
    }

    fn current(&self) -> &wgpu::Texture {
        &self.tex[self.cur]
    }
}
