//! VARASYS PolyMeter — PM_G-1 "Grid" firmware (Rust edition).
//!
//! Target: a *plain* Raspberry Pi Pico (RP2040, Cortex-M0+) wearing the Pimoroni Pico Scroll Pack
//! (PIM545): a 17x7 single-colour white LED matrix on an IS31FL3731 (I2C @ 0x74) + 4 buttons
//! (A/B/X/Y). No speaker, no touch. This is the Rust sibling of `pico-scroll/app.py` and the UI
//! prototype for the eventual `pm-grid` board (see docs/rust-port.md).
//!
//! Scope of this milestone (LED-first, like pm-kit's bring-up): the IS31FL3731 driver, the
//! polymeter scheduler (driven by the shared `track-format` crate — the cross-impl contract),
//! 4-button input, three LED views (Ticker / Grid / Pendulum), the built-in set lists, and
//! per-track ramp + gap-trainer. Audio is over USB-MIDI on this board, which — like pm-kit — is
//! the NEXT milestone (along with live-sync SysEx, firmware push, practice log). No speaker here.
//!
//! Pins (Pimoroni Pico Scroll Pack, verified against pico-scroll/app.py):
//!   I2C0  SDA=GP4  SCL=GP5  (IS31FL3731 @ 0x74; relies on the RP2040's INTERNAL pull-ups)
//!   Buttons (active-low): A=GP12  B=GP13  X=GP14  Y=GP15

#![no_std]
#![no_main]

extern crate alloc;
mod fonts;
use fonts::{build_name_cols, DIGITS};
use alloc::collections::VecDeque;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use embedded_alloc::LlffHeap as Heap;

use cortex_m::delay::Delay;
use defmt::info;
use defmt_rtt as _; // global defmt logger over RTT (read by `probe-rs run`)
use embedded_hal::digital::InputPin;
use embedded_hal::i2c::I2c;
use panic_probe as _; // prints the panic over defmt, then halts
use rp2040_hal as hal;
use rp2040_hal::fugit::RateExtU32;
use rp2040_hal::Clock;
use track_format::End;
use usb_device::prelude::*;
use usb_device::bus::UsbBusAllocator;
use usbd_midi::UsbMidiClass;
use usbd_storage::subclass::scsi::{Scsi, ScsiCommand};
use usbd_storage::subclass::Command;
use usbd_storage::transport::bbb::{BulkOnly, BulkOnlyError};
use usbd_storage::transport::TransportError;

#[global_allocator]
static HEAP: Heap = Heap::empty();

/// Second-stage bootloader for the Pico's W25Q080-style QSPI flash (placed at flash start).
#[link_section = ".boot2"]
#[used]
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;

// ---- USB Mass Storage: a 1 MB FAT volume in the upper flash (the host formats it on first use) ----
const FS_BLOCK_SIZE: u32 = 4096;
const FS_BLOCKS: u32 = 256; // 256 × 4 KB = 1 MB
const FS_LEN: usize = (FS_BLOCK_SIZE * FS_BLOCKS) as usize;
const MSC_PACKET: u16 = 64;
const MSC_MAX_LUN: u8 = 0;

/// The filesystem flash region (see memory.x `.filesystem`, NOLOAD). We never read it *through* this
/// static (it would const-fold to 0); reads go via raw pointers and writes via `rp2040-flash`.
#[link_section = ".filesystem"]
#[used]
static FILESYSTEM: [u8; FS_LEN] = [0u8; FS_LEN];

/// Bulk-Only-Transport scratch buffer for usbd-storage (one block).
static mut MSC_BUF: core::mem::MaybeUninit<[u8; FS_BLOCK_SIZE as usize]> =
    core::mem::MaybeUninit::uninit();

/// A freshly-formatted, empty FAT12 volume labelled "PM_G-1" (boot sector + 2 FATs + root dir, the
/// first 7 × 4 KB sectors made by `mkfs.fat -F12 -S4096 -n PM_G-1`). Written to flash when the drive
/// isn't already ours, so the host shows it as "PM_G-1" instead of a leftover CircuitPython volume.
static FAT_TEMPLATE: &[u8] = include_bytes!("fat_template.bin");

// ============================== ON-DEVICE FAT (read the drive) ==============================
// Read-only access to the Mass Storage FAT volume so the device can load programs.json (user set
// lists). Writes go through MSC (the host); here we only read + (re)format to set the label.
#[derive(Debug)]
struct FsErr;
impl fatfs::IoError for FsErr {
    fn is_interrupted(&self) -> bool {
        false
    }
    fn new_unexpected_eof_error() -> Self {
        FsErr
    }
    fn new_write_zero_error() -> Self {
        FsErr
    }
}

/// A byte cursor over the flash `.filesystem` region. Reads hit flash; writes are discarded (we only
/// read on-device — formatting is a separate bulk flash write below).
struct FlashIo {
    pos: u64,
}
impl FlashIo {
    fn new() -> Self {
        FlashIo { pos: 0 }
    }
}
impl fatfs::IoBase for FlashIo {
    type Error = FsErr;
}
impl fatfs::Read for FlashIo {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize, FsErr> {
        let p = self.pos as usize;
        if p >= FS_LEN {
            return Ok(0);
        }
        let n = buf.len().min(FS_LEN - p);
        // black_box the base so the compiler can't const-fold the (zero-init, NOLOAD) static to 0
        let base = core::hint::black_box(FILESYSTEM.as_ptr());
        unsafe { core::ptr::copy_nonoverlapping(base.add(p), buf.as_mut_ptr(), n) };
        self.pos += n as u64;
        Ok(n)
    }
}
impl fatfs::Write for FlashIo {
    fn write(&mut self, buf: &[u8]) -> Result<usize, FsErr> {
        self.pos += buf.len() as u64; // discard (on-device is read-only)
        Ok(buf.len())
    }
    fn flush(&mut self) -> Result<(), FsErr> {
        Ok(())
    }
}
impl fatfs::Seek for FlashIo {
    fn seek(&mut self, from: fatfs::SeekFrom) -> Result<u64, FsErr> {
        let np: i64 = match from {
            fatfs::SeekFrom::Start(o) => o as i64,
            fatfs::SeekFrom::End(o) => FS_LEN as i64 + o,
            fatfs::SeekFrom::Current(o) => self.pos as i64 + o,
        };
        if np < 0 {
            return Err(FsErr);
        }
        self.pos = np as u64;
        Ok(self.pos)
    }
}

/// Write the blank PM_G-1 template over the metadata sectors (erases the leftover volume → empty).
/// One 4 KB sector per call — exactly the proven MSC write pattern (a multi-sector call is riskier).
fn format_pmg1() {
    let base = FILESYSTEM.as_ptr() as u32 & 0x00ff_ffff & !0xfff; // flash offset 0x100000
    let sz = FS_BLOCK_SIZE as usize;
    let sectors = FAT_TEMPLATE.len() / sz;
    for s in 0..sectors {
        let mut sector = [0u8; FS_BLOCK_SIZE as usize];
        sector.copy_from_slice(&FAT_TEMPLATE[s * sz..(s + 1) * sz]);
        let faddr = base + (s as u32) * FS_BLOCK_SIZE;
        cortex_m::interrupt::free(|_| unsafe {
            rp2040_flash::flash::flash_range_erase_and_program(faddr, &sector, false);
        });
    }
}

/// Read programs.json (if present) into user set lists. READ-ONLY — never writes the FAT, so it's
/// safe to call at runtime even while the host has the drive mounted.
fn read_programs_json() -> Vec<SetList> {
    let io = FlashIo::new();
    let mut out = Vec::new();
    if let Ok(fs) = fatfs::FileSystem::new(io, fatfs::FsOptions::new()) {
        if let Ok(mut f) = fs.root_dir().open_file("programs.json") {
            let mut data = Vec::new();
            let mut tmp = [0u8; 256];
            loop {
                match fatfs::Read::read(&mut f, &mut tmp) {
                    Ok(0) => break,
                    Ok(n) => data.extend_from_slice(&tmp[..n]),
                    _ => break,
                }
                if data.len() > 64 * 1024 {
                    break; // sanity cap
                }
            }
            if let Ok(s) = core::str::from_utf8(&data) {
                out = parse_setlists(s);
            }
            info!("fat: programs.json -> {} user set list(s)", out.len());
        }
    }
    out
}

/// Boot path: if the drive isn't a "PM_G-1"-labelled FAT, (re)format it, then read programs.json.
/// The format flash-write only happens here (at boot, before USB) — runtime re-reads never write.
fn read_user_setlists() -> Vec<SetList> {
    let is_ours = {
        let io = FlashIo::new();
        match fatfs::FileSystem::new(io, fatfs::FsOptions::new()) {
            Ok(fs) => fs
                .read_volume_label_from_root_dir()
                .ok()
                .flatten()
                .as_deref()
                == Some("PM_G-1"),
            Err(_) => false,
        }
    };
    if !is_ours {
        info!("fat: (re)formatting drive as PM_G-1");
        format_pmg1();
    }
    read_programs_json()
}

/// Build the runtime set-list table: built-ins (static → owned) followed by the drive's set lists.
fn build_setlists(user: Vec<SetList>) -> Vec<SetList> {
    let mut v: Vec<SetList> = SETLISTS
        .iter()
        .map(|(title, items)| SetList {
            title: String::from(*title),
            items: items.iter().map(|(n, p)| (String::from(*n), String::from(*p))).collect(),
        })
        .collect();
    v.extend(user);
    v.retain(|s| !s.items.is_empty()); // never keep an empty set list (would `% 0` in load/next)
    v
}

/// Read a JSON string starting at the opening quote `b[start]`; returns (value, index-after-close).
fn json_str(b: &[u8], start: usize) -> Option<(String, usize)> {
    let mut s = String::new();
    let mut i = start + 1;
    while i < b.len() {
        match b[i] {
            b'\\' => {
                i += 1;
                let e = *b.get(i)?;
                s.push(match e {
                    b'n' => '\n',
                    b't' => '\t',
                    b'r' => '\r',
                    _ => e as char,
                });
                i += 1;
            }
            b'"' => return Some((s, i + 1)),
            c => {
                s.push(c as char);
                i += 1;
            }
        }
    }
    None
}

/// Tolerant extractor for the editor's programs.json: walks `"title"`/`"name"`/`"prog"` string
/// fields in document order. Each `title` starts a set list; each `name`+`prog` appends an item.
fn parse_setlists(json: &str) -> Vec<SetList> {
    let b = json.as_bytes();
    let mut out: Vec<SetList> = Vec::new();
    let mut pending_name: Option<String> = None;
    let mut i = 0;
    while i < b.len() {
        if b[i] != b'"' {
            i += 1;
            continue;
        }
        let (key, after_key) = match json_str(b, i) {
            Some(kv) => kv,
            None => break,
        };
        // is this string a key (followed by ':' then a string value)?
        let mut j = after_key;
        while j < b.len() && (b[j] as char).is_ascii_whitespace() {
            j += 1;
        }
        if j >= b.len() || b[j] != b':' {
            i = after_key;
            continue;
        }
        j += 1;
        while j < b.len() && (b[j] as char).is_ascii_whitespace() {
            j += 1;
        }
        if j >= b.len() || b[j] != b'"' {
            i = j;
            continue;
        }
        let (val, after_val) = match json_str(b, j) {
            Some(kv) => kv,
            None => break,
        };
        match key.as_str() {
            "title" => out.push(SetList { title: val, items: Vec::new() }),
            "name" => pending_name = Some(val),
            "prog" => {
                let name = pending_name.take().unwrap_or_else(|| String::from("?"));
                if out.is_empty() {
                    out.push(SetList { title: String::from("My set list"), items: Vec::new() });
                }
                out.last_mut().unwrap().items.push((name, val));
            }
            _ => {}
        }
        i = after_val;
    }
    out.retain(|s| !s.items.is_empty());
    out
}

const XTAL_FREQ_HZ: u32 = 12_000_000;
const MATRIX_ADDR: u8 = 0x74;
const DEVICE_ID: &str = "G"; // reported on the SysEx version query (0x02→0x03)
const APP_VERSION: &str = "0.1.0";

// Brightness ladder (matches pico-scroll/app.py BRIGHTNESS=160 with the same scaling).
const BRIGHTNESS: u8 = 160; // accent
const NAME_BRIGHT: u8 = 120; // ticker name pixels

// ============================== IS31FL3731 DRIVER (bulk framebuffer) ==============================
// Faithful port of pico-scroll/app.py's `Matrix`: keep a 144-byte PWM framebuffer and push the
// WHOLE thing in one I2C block write per frame (per-pixel I2C is far too slow to animate). The
// Scroll Pack wires the 17x7 matrix with the Scroll pHAT HD pixel map.
fn pixel_addr(x: i32, y: i32) -> usize {
    let (x, y) = if x <= 8 { (8 - x, 6 - y) } else { (x - 8, y - 8) };
    (x * 16 + y) as usize
}

struct Matrix<I> {
    i2c: I,
    fb: [u8; 145], // fb[0] = COLOR register offset (0x24); fb[1..] = 144 PWM bytes
}

impl<I: I2c> Matrix<I> {
    fn new(i2c: I, delay: &mut Delay) -> Self {
        let mut m = Matrix { i2c, fb: [0u8; 145] };
        m.fb[0] = 0x24;
        // --- config (mirrors pico-scroll/app.py Matrix.__init__) ---
        let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x0B]); // select Function (config) bank
        let _ = m.i2c.write(MATRIX_ADDR, &[0x0A, 0x00]); // software shutdown while configuring
        let mut cfg = [0u8; 14];
        cfg[0] = 0x00; // clear config regs 0x00..0x0C (Picture Mode, frame 0, audiosync off)
        let _ = m.i2c.write(MATRIX_ADDR, &cfg);
        let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x00]); // select frame 0
        let mut led_ctrl = [0xFFu8; 19];
        led_ctrl[0] = 0x00; // LED-control regs 0x00..0x11 -> enable every LED
        let _ = m.i2c.write(MATRIX_ADDR, &led_ctrl);
        let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x0B]);
        let _ = m.i2c.write(MATRIX_ADDR, &[0x0A, 0x01]); // normal operation
        let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x00]); // frame 0 for all PWM writes
        delay.delay_ms(1);
        m.show();
        m
    }

    fn clear(&mut self) {
        for b in self.fb[1..].iter_mut() {
            *b = 0;
        }
    }

    fn fill(&mut self, v: u8) {
        for b in self.fb[1..].iter_mut() {
            *b = v;
        }
    }

    fn get(&self, x: i32, y: i32) -> u8 {
        if (0..17).contains(&x) && (0..7).contains(&y) {
            self.fb[1 + pixel_addr(x, y)]
        } else {
            0
        }
    }

    fn set(&mut self, x: i32, y: i32, v: u8) {
        if (0..17).contains(&x) && (0..7).contains(&y) {
            self.fb[1 + pixel_addr(x, y)] = v;
        }
    }

    /// Brightest-wins set (matches the views' `if val > m.get(...)` guard).
    fn set_max(&mut self, x: i32, y: i32, v: u8) {
        if v > self.get(x, y) {
            self.set(x, y, v);
        }
    }

    fn show(&mut self) {
        let _ = self.i2c.write(MATRIX_ADDR, &self.fb);
    }
}

// ============================== BUILT-IN SET LISTS (same as Kit/Explorer) ==============================
type Item = (&'static str, &'static str);
type BuiltinSet = (&'static str, &'static [Item]);

/// A runtime set list (built-ins converted to owned + any loaded from the drive's programs.json).
struct SetList {
    #[allow(dead_code)] // parsed from programs.json; not shown yet (Ticker shows the track name)
    title: String,
    items: Vec<(String, String)>, // (name, program-string)
}

static STYLES: &[Item] = &[
    ("Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
    ("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
    ("Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
    ("Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
    ("Nanigo (6/8 bembe)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"),
    ("6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"),
    ("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
    ("5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"),
];
static PRACTICE: &[Item] = &[
    ("5 over 4 polyrhythm", "t100;kick:4;claves:5~"),
    ("3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"),
    ("2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"),
    ("Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"),
    ("Tempo builder 80 up", "t80;woodblock:4;rmp80/4/4"),
    ("Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"),
];
static SONG: &[Item] = &[
    ("Intro - hats & kick", "t88;b4;kick:4=X.x.;hatClosed:4/2=gggggggg"),
    ("Groove in - backbeat", "t88;b4;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"),
    ("Half-time shuffle", "t92;b4;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
    ("Build - ramp 92-120", "t92;b4;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"),
    ("Four-on-the-floor (909)", "t124;b4;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"),
    ("Samba break (2/4)", "t116;b4;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
    ("Peak - 16ths", "t132;b4;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"),
    ("Outro - ramp down", "t132;b4;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"),
];
static SETLISTS: &[BuiltinSet] = &[("Styles", STYLES), ("Practice", PRACTICE), ("Song", SONG)];

// ============================== APP STATE ==============================
const NS_PER_MIN: i64 = 60_000_000_000;

#[derive(Clone, Copy, PartialEq)]
enum View {
    Ticker,
    Grid,
    Pendulum,
}

/// A next track preloaded (parsed + durations computed) so the playback-flow advance is seamless.
struct Pending {
    track: track_format::Track,
    durs: Vec<Vec<i64>>,
    item: usize,
    name: String,
    name_cols: Vec<u8>,
}

struct App {
    setlists: Vec<SetList>, // built-ins (owned) + any loaded from the drive
    // current program
    track: track_format::Track,
    name: String,
    name_cols: Vec<u8>, // 3x5 column slices of the (uppercased) name + trailing gap
    sl: usize,          // set-list index
    item: usize,        // item within the set list
    // tempo + scheduler
    tempo: i64,
    ramp_base: i64,
    durs: Vec<Vec<i64>>, // per-lane per-step durations (ns)
    next: Vec<i64>,      // per-lane next fire time (ns)
    step: Vec<i32>,      // per-lane current step (-1 = not started)
    m_steps: i64,
    lastbar: i64,
    muted: bool, // gap-trainer mute
    playing: bool,
    // view / animation
    view: View,
    scroll_off: i32,
    scroll_total: i32,
    beatflash: u8,
    beatflash_off: i64,
    bpm_flash_off: i64,  // while >0 and active, Grid/Pendulum briefly show the Ticker so nudges are visible
    full_flash_off: i64, // strobe the WHOLE matrix bright on the downbeat ("the 1")
    // --- USB-MIDI TX queue (notes + SysEx, drained one-per-poll) + live-sync state ---
    tx_q: VecDeque<[u8; 4]>, // outgoing USB-MIDI event packets
    sx_buf: Vec<u8>,         // incoming SysEx assembler (between 0xF0 and 0xF7)
    sx_on: bool,
    sync_origin: String, // our session id; we ignore frames stamped with it (echo guard)
    sync_seq: u32,
    sync_armed: bool,    // a peer (the editor) is connected
    sync_applying: bool, // suppress re-broadcast while applying a received frame
    sync_hb_next: i64,   // next FULL heartbeat time (ns)
    // --- playback flow (rep/end auto-advance) ---
    pending: Option<Pending>, // next track, preloaded one bar early
    advance: bool,            // set at a seam boundary → do_advance() after the lane loop
    seam_ns: i64,             // the exact bar-boundary time the next track starts at
    continue_on: bool,        // "continuous" mode: treat a bars-track with no end= as end=next
    // --- MIDI clock out (24 PPQN, so a DAW can slave to the Grid) ---
    clock_out: bool,
    clock_next: i64,
    // --- MIDI clock in (slave the Grid's tempo to an external 24-PPQN clock) ---
    clock_in: bool,
    clock_in_last: i64, // timestamp of the last F8 (ns); 0 = waiting for the first
    clock_in_avg: i64,  // EMA of the F8 interval (ns)
    slaved: bool,       // currently following an external clock
}

fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 {
    let beat = NS_PER_MIN / tempo.max(1);
    let m = &track.lanes[0];
    let beats = (m.levels.len().max(1) / m.sub.max(1) as usize) as i64;
    beat * beats.max(1)
}

const SCROLL_GAP: i32 = 13; // blank columns between repeats (>= name region width) for a clean loop

impl App {
    fn new(now_ns: i64, user: Vec<SetList>) -> Self {
        let setlists = build_setlists(user);
        let prog = setlists[0].items[0].1.clone();
        let track = track_format::parse(&prog);
        let tempo = track.bpm;
        let mut app = App {
            setlists,
            track,
            name: String::new(),
            name_cols: Vec::new(),
            sl: 0,
            item: 0,
            tempo,
            ramp_base: tempo,
            durs: Vec::new(),
            next: Vec::new(),
            step: Vec::new(),
            m_steps: 0,
            lastbar: -1,
            muted: false,
            playing: false,
            view: View::Ticker,
            scroll_off: 0,
            scroll_total: 1,
            beatflash: 0,
            beatflash_off: 0,
            bpm_flash_off: 0,
            full_flash_off: 0,
            tx_q: VecDeque::new(),
            sx_buf: Vec::new(),
            sx_on: false,
            // origin derived from the boot timer (distinct from the editor's "e…"/"d…" ids)
            sync_origin: alloc::format!("g{:06x}", (now_ns / 1000) as u32 & 0xff_ffff),
            sync_seq: 0,
            sync_armed: false,
            sync_applying: false,
            sync_hb_next: 0,
            pending: None,
            advance: false,
            seam_ns: 0,
            continue_on: false,
            clock_out: true,
            clock_next: 0,
            clock_in: true,
            clock_in_last: 0,
            clock_in_avg: 0,
            slaved: false,
        };
        app.load(0, 0, now_ns);
        app
    }

    fn rebuild_durs(&mut self) {
        let mbar = master_bar_ns(&self.track, self.tempo);
        self.durs = self
            .track
            .lanes
            .iter()
            .map(|l| track_format::schedule::lane_durs(l, self.tempo, mbar))
            .collect();
    }

    fn reset_clock(&mut self, now_ns: i64) {
        let n = self.track.lanes.len();
        self.next = alloc::vec![now_ns; n];
        self.step = alloc::vec![-1i32; n];
        self.m_steps = 0;
        self.lastbar = -1;
        self.clock_next = now_ns;
    }

    fn load(&mut self, sl: usize, item: usize, now_ns: i64) {
        self.sl = sl % self.setlists.len();
        let n = self.setlists[self.sl].items.len();
        self.item = item % n;
        let (name, prog) = self.setlists[self.sl].items[self.item].clone();
        self.track = track_format::parse(&prog);
        self.name = name;
        self.name_cols = build_name_cols(&self.name);
        self.scroll_total = self.name_cols.len() as i32 + SCROLL_GAP;
        self.scroll_off = 0;
        self.tempo = self.track.bpm;
        self.ramp_base = self.tempo;
        self.muted = false;
        self.pending = None;
        self.advance = false;
        self.rebuild_durs();
        self.reset_clock(now_ns);
    }

    /// Live re-read: replace the user set lists from a fresh programs.json without disturbing the
    /// currently-playing track (just makes the new lists reachable via B-hold). Clamps indices.
    fn reload_user(&mut self, user: Vec<SetList>) {
        self.setlists = build_setlists(user);
        if self.sl >= self.setlists.len() {
            self.sl = 0;
        }
        if self.item >= self.setlists[self.sl].items.len() {
            self.item = 0;
        }
    }

    fn next_track(&mut self, now_ns: i64) {
        let n = self.setlists[self.sl].items.len();
        self.load(self.sl, (self.item + 1) % n, now_ns);
        let sel = alloc::format!("sel={}/{}", self.sl, self.item);
        self.sync_broadcast(&sel);
    }

    fn next_setlist(&mut self, now_ns: i64) {
        self.load((self.sl + 1) % self.setlists.len(), 0, now_ns);
        let sel = alloc::format!("sel={}/{}", self.sl, self.item);
        self.sync_broadcast(&sel);
    }

    fn set_bpm(&mut self, v: i64, now_ns: i64) {
        let v = v.clamp(5, 300);
        if v != self.tempo {
            self.tempo = v;
            self.rebuild_durs();
            self.bpm_flash_off = now_ns + 700_000_000;
            let evt = alloc::format!("bpm={}", v);
            self.sync_broadcast(&evt);
        }
    }

    fn toggle(&mut self, now_ns: i64) {
        self.playing = !self.playing;
        if self.playing {
            self.reset_clock(now_ns);
            if self.clock_out {
                self.tx_q.push_back([0x0F, 0xFA, 0, 0]); // MIDI Start
            }
        } else if self.clock_out {
            self.tx_q.push_back([0x0F, 0xFC, 0, 0]); // MIDI Stop
        }
        self.sync_broadcast(if self.playing { "play" } else { "stop" });
    }

    fn cycle_view(&mut self) {
        self.view = match self.view {
            View::Ticker => View::Grid,
            View::Grid => View::Pendulum,
            View::Pendulum => View::Ticker,
        };
    }

    /// Ramp + gap-trainer + playback-flow at a master-bar boundary. Returns the new tempo if the
    /// ramp changed it (applied by the caller after the lane loop, so we never mutate `durs` mid-tick).
    fn on_new_bar(&mut self, bar: i64) -> Option<i64> {
        // playback flow: rep/end auto-advance (total = bars * rep cycles)
        if let Some((total, action)) = self.end_plan() {
            let goto_off = match action {
                End::Goto(o) => Some(o),
                End::Stop => None,
            };
            // preload the next track one bar early so the swap is seamless
            if let Some(o) = goto_off {
                if self.pending.is_none() && bar == total - 1 {
                    let tgt = self.goto_target(o);
                    self.prepare_next(tgt);
                }
            }
            if bar > 0 && bar == total {
                match goto_off {
                    None => {
                        self.playing = false;
                        self.sync_broadcast("stop");
                    }
                    Some(o) => {
                        if self.pending.is_none() {
                            let tgt = self.goto_target(o);
                            self.prepare_next(tgt);
                        }
                        if self.pending.is_some() {
                            self.seam_ns = self.next[0]; // the master lane's current bar boundary
                            self.advance = true;
                        }
                    }
                }
            }
        }
        // gap-trainer mute
        if let Some(t) = &self.track.trainer {
            let cyc = t.play + t.mute;
            self.muted = cyc > 0 && (bar % cyc) >= t.play;
        }
        // tempo ramp (suppressed while slaved to an external clock — it owns the tempo)
        if let (false, Some(r)) = (self.slaved, &self.track.ramp) {
            let steps0 = self.track.lanes[0].levels.len().max(1) as i64;
            let bar_pos = self.m_steps / steps0;
            let seg_bar = if self.track.bars > 0 {
                bar_pos % self.track.bars
            } else {
                bar_pos
            };
            let new = (self.ramp_base + seg_bar / r.every.max(1) * r.amt).clamp(5, 300);
            if new != self.tempo {
                return Some(new);
            }
        }
        None
    }

    /// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit and
    /// queues a USB-MIDI note-on (ch10) per lane hit onto `tx_q` (drained by the main loop).
    fn tick(&mut self, now_ns: i64) {
        // drop the external-clock lock if ticks stopped arriving (>1s gap)
        if self.slaved && now_ns - self.clock_in_last > 1_000_000_000 {
            self.slaved = false;
        }
        if !self.playing {
            return;
        }
        let nlanes = self.track.lanes.len();
        let mut fired_best = 0u8;
        let mut pending_tempo: Option<i64> = None;
        for li in 0..nlanes {
            if self.advance {
                break;
            }
            let steps = self.durs[li].len().max(1) as i32;
            while now_ns >= self.next[li] {
                self.step[li] = (self.step[li] + 1) % steps;
                if li == 0 {
                    if self.step[li] == 0 {
                        // the downbeat — strobe the entire matrix bright
                        self.full_flash_off = now_ns + 80_000_000;
                    }
                    self.m_steps += 1;
                    let bar = (self.m_steps - 1) / steps as i64;
                    if bar != self.lastbar {
                        self.lastbar = bar;
                        if let Some(t) = self.on_new_bar(bar) {
                            pending_tempo = Some(t);
                        }
                        if self.advance {
                            break; // seam reached — stop advancing this lane; do_advance() below
                        }
                    }
                }
                let s = self.step[li] as usize;
                let lvl = if self.track.lanes[li].mute {
                    0
                } else {
                    self.track.lanes[li].levels[s]
                };
                if lvl > 0 && !self.muted {
                    let note = gm_note(self.track.lanes[li].sound.as_str());
                    if self.tx_q.len() < 128 {
                        self.tx_q.push_back([0x09, 0x99, note, midi_vel(lvl)]); // note-on, ch10
                    }
                    if prio(lvl) > prio(fired_best) {
                        fired_best = lvl;
                    }
                }
                self.next[li] += self.durs[li][s].max(1);
            }
        }
        if fired_best > 0 {
            self.beatflash = fired_best;
            self.beatflash_off = now_ns + 70_000_000;
        }
        if self.advance {
            self.advance = false;
            self.do_advance(); // swap to the preloaded next track at the seam
        } else if let Some(t) = pending_tempo {
            // tempo change keeps step counts identical (only durations scale) → safe to swap durs
            self.tempo = t;
            self.rebuild_durs();
        }
        // MIDI clock out: emit 24-PPQN F8 ticks against the wall clock (not while we're the slave)
        if self.clock_out && !self.slaved {
            let tick_ns = ((NS_PER_MIN / self.tempo.max(1)) / 24).max(1);
            while now_ns >= self.clock_next {
                if self.tx_q.len() < 200 {
                    self.tx_q.push_back([0x0F, 0xF8, 0, 0]); // MIDI Clock
                }
                self.clock_next += tick_ns;
            }
        }
    }
}

// ============================== LIVE-SYNC (USB-MIDI SysEx; docs/livesync-protocol.md) ==============================
impl App {
    /// Feed one received MIDI byte: assembles SysEx (0xF0..0xF7) and handles realtime clock
    /// (0xF8 tick, 0xFA/0xFB start, 0xFC stop). Called for the data bytes of incoming USB-MIDI packets.
    fn feed_midi(&mut self, b: u8, now_ns: i64) {
        match b {
            0xF0 => {
                self.sx_buf.clear();
                self.sx_on = true;
            }
            0xF7 => {
                if self.sx_on {
                    self.handle_sysex(now_ns);
                }
                self.sx_on = false;
            }
            0xF8 => {
                if self.clock_in {
                    self.slave_tick(now_ns);
                }
            }
            0xFA | 0xFB => {
                if self.clock_in {
                    self.slave_start(now_ns);
                }
            }
            0xFC => {
                if self.clock_in {
                    self.slave_stop();
                }
            }
            _ if b >= 0xF8 => {} // other realtime (active sensing etc.)
            _ => {
                if self.sx_on && self.sx_buf.len() < 2048 {
                    self.sx_buf.push(b);
                }
            }
        }
    }

    /// One external clock tick (0xF8): track the EMA interval and derive tempo (24 PPQN).
    fn slave_tick(&mut self, now_ns: i64) {
        if self.clock_in_last == 0 {
            self.clock_in_last = now_ns;
            self.slaved = true;
            return;
        }
        let interval = now_ns - self.clock_in_last;
        self.clock_in_last = now_ns;
        if !(8_300_000..=500_000_000).contains(&interval) {
            return; // out of 5..300 BPM range → ignore (jitter/glitch)
        }
        self.clock_in_avg = if self.clock_in_avg == 0 {
            interval
        } else {
            (self.clock_in_avg * 7 + interval) / 8
        };
        let new_bpm = (NS_PER_MIN / (self.clock_in_avg * 24)).clamp(5, 300);
        if new_bpm != self.tempo {
            self.tempo = new_bpm;
            self.rebuild_durs();
        }
        self.slaved = true;
    }

    fn slave_start(&mut self, now_ns: i64) {
        if !self.playing {
            self.playing = true;
            self.reset_clock(now_ns);
        }
        self.clock_in_last = 0;
        self.clock_in_avg = 0;
    }

    fn slave_stop(&mut self) {
        self.playing = false;
        self.clock_in_last = 0;
        self.clock_in_avg = 0;
        self.slaved = false;
    }

    /// Build `F0 7D <op> <text(ASCII-clamped)> F7`, packetize into 4-byte USB-MIDI events, queue them.
    fn sx_send(&mut self, op: u8, text: &str) {
        // Bound the TX queue: if the host stopped draining MIDI-IN (e.g. editor closed without a BYE
        // while sync_armed), drop the message rather than grow the heap forever. The 5 s heartbeat
        // re-syncs once the host returns.
        if self.tx_q.len() > 256 {
            return;
        }
        let mut f: Vec<u8> = Vec::with_capacity(text.len() + 4);
        f.push(0xF0);
        f.push(0x7D);
        f.push(op);
        for &c in text.as_bytes() {
            f.push(if c < 0x80 { c } else { 0x3F });
        }
        f.push(0xF7);
        let mut i = 0;
        while f.len() - i > 3 {
            self.tx_q.push_back([0x04, f[i], f[i + 1], f[i + 2]]); // SysEx start/continue
            i += 3;
        }
        match f.len() - i {
            1 => self.tx_q.push_back([0x05, f[i], 0, 0]),
            2 => self.tx_q.push_back([0x06, f[i], f[i + 1], 0]),
            3 => self.tx_q.push_back([0x07, f[i], f[i + 1], f[i + 2]]),
            _ => {}
        }
    }

    fn handle_sysex(&mut self, now_ns: i64) {
        if self.sx_buf.len() < 2 || self.sx_buf[0] != 0x7D {
            return;
        }
        let cmd = self.sx_buf[1];
        if cmd == 0x02 {
            // version query → reply 0x03 "G;<version>"
            let reply = alloc::format!("{};{}", DEVICE_ID, APP_VERSION);
            info!("sysex: version query → {}", reply.as_str());
            self.sx_send(0x03, &reply);
            return;
        }
        if !(0x40..=0x43).contains(&cmd) {
            return;
        }
        // payload text (printable ASCII only)
        let text: String = self.sx_buf[2..]
            .iter()
            .cloned()
            .filter(|&b| (0x20..0x7F).contains(&b))
            .map(|b| b as char)
            .collect();
        let origin = text.splitn(2, ';').next().unwrap_or("");
        if origin == self.sync_origin.as_str() {
            return; // ignore our own echo
        }
        self.sync_armed = true;
        match cmd {
            0x40 => {
                info!("sysex: HELLO → FULL");
                self.sync_broadcast_full(now_ns);
            }
            0x43 => {
                info!("sysex: BYE");
                self.sync_armed = false;
            }
            0x41 => {
                // origin;seq;running;sl;item;patch  (patch may contain ';' → splitn 6)
                let mut it = text.splitn(6, ';');
                let _ = it.next(); // origin
                let _ = it.next(); // seq
                let running = it.next() == Some("1");
                let _ = it.next(); // sl
                let _ = it.next(); // item
                if let Some(patch) = it.next() {
                    let patch = patch.to_string();
                    info!("sysex: FULL running={} ({} chars)", running, patch.len());
                    self.apply_full(running, &patch, now_ns);
                }
            }
            0x42 => {
                // origin;seq;evt
                let mut it = text.splitn(3, ';');
                let _ = it.next();
                let _ = it.next();
                if let Some(evt) = it.next() {
                    let evt = evt.to_string();
                    info!("sysex: DELTA {}", evt.as_str());
                    self.apply_delta(&evt, now_ns);
                }
            }
            _ => {}
        }
    }

    /// DELTA broadcast (transport/tempo/track changes from on-device input).
    fn sync_broadcast(&mut self, evt: &str) {
        if !self.sync_armed || self.sync_applying {
            return;
        }
        let text = alloc::format!("{};{};{}", self.sync_origin, self.sync_seq, evt);
        self.sync_seq = self.sync_seq.wrapping_add(1);
        self.sx_send(0x42, &text);
    }

    /// FULL broadcast: our whole program + transport (HELLO reply + ~5s heartbeat).
    fn sync_broadcast_full(&mut self, now_ns: i64) {
        if !self.sync_armed {
            return;
        }
        self.track.bpm = self.tempo; // reflect the live tempo in the serialized patch
        let patch = track_format::serialize(&self.track);
        let text = alloc::format!(
            "{};{};{};{};{};{}",
            self.sync_origin,
            self.sync_seq,
            if self.playing { 1 } else { 0 },
            self.sl,
            self.item,
            patch
        );
        self.sync_seq = self.sync_seq.wrapping_add(1);
        self.sx_send(0x41, &text);
        self.sync_hb_next = now_ns + 5_000_000_000;
    }

    fn apply_full(&mut self, running: bool, patch: &str, now_ns: i64) {
        self.sync_applying = true;
        self.track = track_format::parse(patch);
        self.tempo = self.track.bpm;
        self.ramp_base = self.tempo;
        self.muted = false;
        self.rebuild_durs();
        self.reset_clock(now_ns);
        self.playing = running;
        self.sync_applying = false;
    }

    fn apply_delta(&mut self, evt: &str, now_ns: i64) {
        self.sync_applying = true;
        let (key, val) = evt.split_once('=').unwrap_or((evt, ""));
        match key {
            "play" => {
                if !self.playing {
                    self.playing = true;
                    self.reset_clock(now_ns);
                    if self.clock_out {
                        self.tx_q.push_back([0x0F, 0xFA, 0, 0]); // MIDI Start
                    }
                }
            }
            "stop" => {
                if self.playing && self.clock_out {
                    self.tx_q.push_back([0x0F, 0xFC, 0, 0]); // MIDI Stop
                }
                self.playing = false;
            }
            "bpm" => {
                if let Ok(v) = val.parse::<i64>() {
                    self.set_bpm(v, now_ns);
                }
            }
            "sel" => {
                let mut p = val.split('/');
                if let (Some(a), Some(b)) = (p.next(), p.next()) {
                    if let (Ok(sl), Ok(item)) = (a.parse::<usize>(), b.parse::<usize>()) {
                        if sl < self.setlists.len() {
                            self.load(sl, item, now_ns);
                        }
                    }
                }
            }
            "beat" => {
                // li/s/lvl — single-cell level edit
                let mut p = val.split('/');
                if let (Some(a), Some(b), Some(c)) = (p.next(), p.next(), p.next()) {
                    if let (Ok(li), Ok(s), Ok(lvl)) =
                        (a.parse::<usize>(), b.parse::<usize>(), c.parse::<u8>())
                    {
                        if li < self.track.lanes.len() && s < self.track.lanes[li].levels.len() {
                            self.track.lanes[li].levels[s] = lvl & 3;
                        }
                    }
                }
            }
            _ => {} // structural lane= edits arrive as a fresh FULL; nothing to do here
        }
        self.sync_applying = false;
    }

    // ---------- playback flow (rep/end auto-advance) ----------
    /// `(total_bars, action)`: the end-action fires after `bars * rep` cycles. `None` = loop forever.
    fn end_plan(&self) -> Option<(i64, End)> {
        let end = match &self.track.end {
            Some(e) => e.clone(),
            None => {
                if self.continue_on && self.track.bars > 0 {
                    End::Goto(1)
                } else {
                    return None;
                }
            }
        };
        let cyc = if self.track.bars > 0 { self.track.bars } else { 1 };
        let reps = self.track.rep.unwrap_or(1).max(1);
        Some((cyc * reps, end))
    }

    /// Resolve a relative track offset into a set-list item index (clamps below 0, wraps above).
    fn goto_target(&self, offset: i64) -> usize {
        let n = self.setlists[self.sl].items.len() as i64;
        let t = self.item as i64 + offset;
        (if t < 0 {
            0
        } else if t >= n {
            t % n
        } else {
            t
        }) as usize
    }

    /// Preload `target` (parse + per-lane durations) so `do_advance` is just a pointer swap.
    fn prepare_next(&mut self, target: usize) {
        if target == self.item {
            return;
        }
        let (name, prog) = self.setlists[self.sl].items[target].clone();
        let track = track_format::parse(&prog);
        let mbar = master_bar_ns(&track, track.bpm);
        let durs = track
            .lanes
            .iter()
            .map(|l| track_format::schedule::lane_durs(l, track.bpm, mbar))
            .collect();
        let nm = String::from(name);
        let name_cols = build_name_cols(&nm);
        self.pending = Some(Pending { track, durs, item: target, name: nm, name_cols });
    }

    /// Swap to the preloaded next track at the seam time (gapless: every lane restarts at `seam_ns`).
    fn do_advance(&mut self) {
        if let Some(p) = self.pending.take() {
            let seam = self.seam_ns;
            self.track = p.track;
            self.tempo = self.track.bpm;
            self.durs = p.durs;
            self.item = p.item;
            self.name = p.name;
            self.name_cols = p.name_cols;
            self.scroll_total = self.name_cols.len() as i32 + SCROLL_GAP;
            self.ramp_base = self.tempo;
            self.muted = false;
            self.m_steps = 0;
            self.lastbar = -1;
            let n = self.track.lanes.len();
            self.next = alloc::vec![seam; n];
            self.step = alloc::vec![-1i32; n];
            self.clock_next = seam;
            let sel = alloc::format!("sel={}/{}", self.sl, self.item);
            self.sync_broadcast(&sel);
        }
    }
}

fn prio(level: u8) -> u8 {
    match level {
        2 => 3, // accent
        1 => 2, // normal
        3 => 1, // ghost
        _ => 0,
    }
}

fn lvl_bright(lvl: u8) -> u8 {
    match lvl {
        2 => BRIGHTNESS,
        1 => (BRIGHTNESS / 4).max(8),
        3 => (BRIGHTNESS / 16).max(3),
        _ => 0,
    }
}

/// Sound name → General MIDI drum note (channel 10). Ports pico-scroll/app.py's SOUND_GM.
fn gm_note(sound: &str) -> u8 {
    match sound {
        "kick" | "kick808" | "kick909" => 36,
        "snare" | "snare808" | "snare909" => 38,
        "clap" | "clap808" | "clap909" => 39,
        "rim" => 37,
        "hatClosed" | "hat808" | "hat909" => 42,
        "hatOpen" | "openHat808" => 46,
        "ride" | "ride909" => 51,
        "crash" | "crash909" => 49,
        "tomLow" => 41,
        "tom808" | "tomMid" => 45,
        "tomHigh" => 48,
        "tambourine" => 54,
        "cowbell" | "cowbell808" => 56,
        "woodblock" | "jamblock" => 76,
        "claves" => 75,
        _ => 37, // beep / unknown → GM_DEFAULT
    }
}

/// Level → MIDI velocity (accent / normal / ghost). Ports pico-scroll/app.py's MIDI_VEL.
fn midi_vel(level: u8) -> u8 {
    match level {
        2 => 120,
        1 => 90,
        3 => 45,
        _ => 90,
    }
}

// ============================== RENDERING ==============================
fn render<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) {
    // downbeat strobe: the whole matrix at full brightness on "the 1"
    if now_ns < app.full_flash_off {
        m.fill(255);
        m.show();
        return;
    }
    m.clear();
    // a tempo nudge briefly forces the Ticker (so X/Y is visible from Grid/Pendulum)
    let view = if app.view != View::Ticker && now_ns < app.bpm_flash_off {
        View::Ticker
    } else {
        app.view
    };
    match view {
        View::Ticker => draw_ticker(m, app),
        View::Grid => draw_grid(m, app),
        View::Pendulum => draw_pendulum(m, app, now_ns),
    }
    m.show();
}

/// Ticker: a beat strip runs along the top row (cols 0..=10); the track name infinite-scrolls below
/// it (cols 0..=10, rows 2..=6); BPM is pinned to the right rotated 90° CCW — a vertical "hundreds
/// dot-bar" in col 11 (one dot per 100) plus the last two digits rotated into cols 12..=16 (tens at
/// the bottom, units on top; reads bottom→top).
fn draw_ticker<I: I2c>(m: &mut Matrix<I>, app: &App) {
    // top-row beat strip (cols 0..=10): faint marks at each beat (every `sub` steps), a bright
    // playhead at the master lane's current step — spread across the full name-region width.
    if let Some(master) = app.track.lanes.first() {
        let steps = master.levels.len().max(1) as i32;
        let sub = master.sub.max(1) as i32;
        for s in 0..steps {
            if s % sub == 0 {
                m.set_max(s * 11 / steps, 0, 24); // beat tick
            }
        }
        if app.playing && app.step[0] >= 0 {
            m.set(app.step[0] * 11 / steps, 0, 255); // live playhead
        }
    }
    // scrolling name on rows 2..=6 (shifted down one to clear the beat strip)
    let total = app.scroll_total.max(1);
    for sx in 0..=10i32 {
        let i = ((app.scroll_off + sx) % total + total) % total;
        let colbits = if (i as usize) < app.name_cols.len() {
            app.name_cols[i as usize]
        } else {
            0
        };
        for r in 0..5i32 {
            if colbits & (1 << r) != 0 {
                m.set(sx, 2 + r, NAME_BRIGHT);
            }
        }
    }
    // hundreds dot-bar in col 11 (1 dot per 100; nothing under 100)
    let hundreds = (app.tempo / 100) as i32;
    for i in 0..hundreds {
        m.set(11, 6 - 2 * i, BRIGHTNESS);
    }
    // last two digits, rotated 90° CCW into cols 12..=16
    let two = (app.tempo % 100) as usize;
    draw_rot_digit(m, two / 10, 12, 4); // tens  → bottom cell (rows 4..=6)
    draw_rot_digit(m, two % 10, 12, 0); // units → top cell    (rows 0..=2)
}

/// Draw a 3x5 digit rotated 90° CCW at cell origin (cx, cy): occupies 5 cols (cx..=cx+4) x 3 rows
/// (cy..=cy+2). Source pixel (col c, row r) maps to (cx + r, cy + (2 - c)).
fn draw_rot_digit<I: I2c>(m: &mut Matrix<I>, d: usize, cx: i32, cy: i32) {
    let g = DIGITS[d];
    for c in 0..3i32 {
        for r in 0..5i32 {
            if g[r as usize] & (1 << (2 - c)) != 0 {
                m.set(cx + r, cy + (2 - c), BRIGHTNESS);
            }
        }
    }
}

/// Grid: lanes are rows (centred), steps are columns (centred), brightness encodes accent/normal/
/// ghost, a bright playhead column tracks the beat. (Port of pico-scroll `_render_grid`.)
fn draw_grid<I: I2c>(m: &mut Matrix<I>, app: &App) {
    let lanes = &app.track.lanes;
    let n = lanes.len().min(7);
    if n == 0 {
        return;
    }
    let y0 = ((7 - n) / 2) as i32;
    for li in 0..n {
        let l = &lanes[li];
        let steps = l.levels.len().max(1) as i32;
        let y = y0 + li as i32;
        let lit = if app.playing { app.step[li] } else { -1 };
        let off = if steps <= 17 { (17 - steps) / 2 } else { 0 };
        for s in 0..steps {
            let col = if steps <= 17 { s + off } else { s * 17 / steps };
            let lvl = if l.mute { 0 } else { l.levels[s as usize] };
            let val = if s == lit {
                if lvl > 0 {
                    255
                } else {
                    70
                }
            } else {
                lvl_bright(lvl)
            };
            if val > 0 {
                m.set_max(col, y, val);
            }
        }
    }
}

/// Pendulum: a metronome arm bounces across the bar; faint beat ticks along the bottom edge.
/// (Port of pico-scroll `_render_pendulum`.)
fn draw_pendulum<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) {
    if app.track.lanes.is_empty() {
        return;
    }
    let l = &app.track.lanes[0];
    let steps = l.levels.len().max(1) as i64;
    let sub = l.sub.max(1) as i64;
    let beats = (steps / sub).max(1);
    let frac = if app.playing {
        (((app.m_steps - 1).rem_euclid(steps)) as f32) / steps as f32
    } else {
        0.0
    };
    let tri = if frac < 0.5 { frac * 2.0 } else { 2.0 * (1.0 - frac) };
    let col = (tri * 16.0 + 0.5) as i32;
    let flash = if app.beatflash != 0 && now_ns < app.beatflash_off {
        app.beatflash
    } else {
        0
    };
    let val = if flash == 2 {
        255
    } else if flash != 0 {
        150
    } else {
        90
    };
    for y in 0..7 {
        m.set(col, y, val);
    }
    for bi in 0..beats {
        let bc = (bi * 17 / beats) as i32;
        if m.get(bc, 6) < 24 {
            m.set(bc, 6, 24);
        }
    }
}

/// Boot splash: scroll "PM-G1 GRID" once, right-to-left. Doubles as a liveness + pixel-map check.
/// `poll` is called frequently during the per-frame delay so USB enumeration isn't stalled.
fn splash<I: I2c, P: FnMut()>(m: &mut Matrix<I>, delay: &mut Delay, mut poll: P) {
    let cols = build_name_cols("PM-G1 GRID");
    let n = cols.len() as i32;
    let mut off = -16i32;
    while off < n {
        m.clear();
        for x in 0..17i32 {
            let ci = x + off;
            if ci >= 0 && ci < n {
                let colbits = cols[ci as usize];
                for r in 0..5i32 {
                    if colbits & (1 << r) != 0 {
                        m.set(x, 1 + r, BRIGHTNESS);
                    }
                }
            }
        }
        m.show();
        for _ in 0..30 {
            poll();
            delay.delay_us(1500); // ~45ms/frame, polling USB every 1.5ms
        }
        off += 1;
    }
}

// ============================== USB MASS STORAGE (SCSI over Bulk-Only) ==============================
// Adapted from the usbd-storage rp2040 example (apohrebniak/usbd-storage). The host sees a 1 MB
// removable drive; we serve blocks from the flash `.filesystem` region and write them with
// rp2040-flash (erase+program a 4 KB sector). The host owns the FAT format.
struct ScsiState {
    offset: usize, // bytes transferred so far for the in-progress Read/Write
    sense_key: u8,
    sense_asc: u8,
    dirty: bool, // host wrote a block → the main loop schedules a programs.json re-read
    write_buf: [u8; FS_BLOCK_SIZE as usize],
}
impl ScsiState {
    fn new() -> Self {
        ScsiState {
            offset: 0,
            sense_key: 0,
            sense_asc: 0,
            dirty: false,
            write_buf: [0u8; FS_BLOCK_SIZE as usize],
        }
    }
}

fn scsi_command(
    mut command: Command<ScsiCommand, Scsi<BulkOnly<hal::usb::UsbBus, &mut [u8]>>>,
    st: &mut ScsiState,
) -> Result<(), TransportError<BulkOnlyError>> {
    match command.kind {
        ScsiCommand::TestUnitReady { .. } => {
            command.pass();
        }
        ScsiCommand::Inquiry { .. } => {
            command.try_write_data_all(&[
                0x00, 0x80, 0x04, 0x02, 0x20, 0x00, 0x00, 0x00, // std inquiry, removable
                b'V', b'A', b'R', b'A', b'S', b'Y', b'S', b' ', // 8-byte vendor id
                b'P', b'M', b'_', b'G', b'-', b'1', b' ', b'G', b'r', b'i', b'd', b' ', b' ', b' ',
                b' ', b' ', // 16-byte product id
                b'0', b'.', b'1', b'0', // 4-byte revision
            ])?;
            command.pass();
        }
        ScsiCommand::RequestSense { .. } => {
            command.try_write_data_all(&[
                0x70, 0x00, st.sense_key, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                st.sense_asc, 0x00, 0x00, 0x00, 0x00, 0x00,
            ])?;
            st.sense_key = 0;
            st.sense_asc = 0;
            command.pass();
        }
        ScsiCommand::ReadCapacity10 { .. } => {
            let mut data = [0u8; 8];
            data[0..4].copy_from_slice(&u32::to_be_bytes(FS_BLOCKS - 1));
            data[4..8].copy_from_slice(&u32::to_be_bytes(FS_BLOCK_SIZE));
            command.try_write_data_all(&data)?;
            command.pass();
        }
        ScsiCommand::ReadCapacity16 { .. } => {
            let mut data = [0u8; 16];
            data[0..8].copy_from_slice(&u64::to_be_bytes((FS_BLOCKS - 1) as u64));
            data[8..12].copy_from_slice(&u32::to_be_bytes(FS_BLOCK_SIZE));
            command.try_write_data_all(&data)?;
            command.pass();
        }
        ScsiCommand::ReadFormatCapacities { .. } => {
            let mut data = [0u8; 12];
            data[0..4].copy_from_slice(&[0x00, 0x00, 0x00, 0x08]);
            data[4..8].copy_from_slice(&u32::to_be_bytes(FS_BLOCKS));
            data[8] = 0x01;
            let bl = u32::to_be_bytes(FS_BLOCK_SIZE);
            data[9..12].copy_from_slice(&bl[1..4]);
            command.try_write_data_all(&data)?;
            command.pass();
        }
        ScsiCommand::Read { lba, len } => {
            let len = len as u32;
            if st.offset != (len * FS_BLOCK_SIZE) as usize {
                let start = (FS_BLOCK_SIZE * lba) as usize + st.offset;
                let end = ((FS_BLOCK_SIZE * lba) as usize + (FS_BLOCK_SIZE * len) as usize).min(FS_LEN);
                let start = start.min(end);
                // raw-pointer read of the flash region (the static would const-fold to 0)
                let data = unsafe { core::slice::from_raw_parts(FILESYSTEM.as_ptr().add(start), end - start) };
                let count = command.write_data(data)?;
                st.offset += count;
            } else {
                command.pass();
                st.offset = 0;
            }
        }
        ScsiCommand::Write { lba, len } => {
            let len = len as u32;
            if st.offset != (len * FS_BLOCK_SIZE) as usize {
                loop {
                    let start = (FS_BLOCK_SIZE * lba) as usize + st.offset;
                    let block_offset = start % (FS_BLOCK_SIZE as usize);
                    let count = command.read_data(&mut st.write_buf[block_offset..])?;
                    st.offset += count;
                    if count > 0 && (st.offset % (FS_BLOCK_SIZE as usize)) == 0 {
                        // a full 4 KB block accumulated → erase+program that sector
                        info!("msc: write block {}", start / FS_BLOCK_SIZE as usize);
                        st.dirty = true; // drive changed → re-read programs.json once it's idle
                        let faddr = (FILESYSTEM.as_ptr() as u32 & 0x00ff_ffff) + ((start as u32) & !0xfff);
                        cortex_m::interrupt::free(|_| unsafe {
                            rp2040_flash::flash::flash_range_erase_and_program(faddr, &st.write_buf, false);
                        });
                    } else {
                        break;
                    }
                }
                if st.offset == (len * FS_BLOCK_SIZE) as usize {
                    command.pass();
                    st.offset = 0;
                }
            } else {
                command.pass();
                st.offset = 0;
            }
        }
        ScsiCommand::ModeSense6 { .. } => {
            command.try_write_data_all(&[0x03, 0x00, 0x00, 0x00])?;
            command.pass();
        }
        ScsiCommand::ModeSense10 { .. } => {
            command.try_write_data_all(&[0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])?;
            command.pass();
        }
        ref other => {
            info!("msc: unsupported scsi cmd {}", other);
            st.sense_key = 0x05; // illegal request
            st.sense_asc = 0x20; // invalid command operation code
            command.fail();
        }
    }
    Ok(())
}

// ============================== MAIN ==============================
#[rp2040_hal::entry]
fn main() -> ! {
    // heap for track-format (Vec/String). The Pico has 264 KB SRAM; 24 KB is plenty for a track.
    {
        use core::mem::MaybeUninit;
        const HEAP_SIZE: usize = 96 * 1024; // fatfs + owned set lists + track parse (Pico has 264 KB)
        static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
        unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
    }
    info!("== pm-grid boot == heap {} free", HEAP.free());

    let mut pac = hal::pac::Peripherals::take().unwrap();
    let core = hal::pac::CorePeripherals::take().unwrap();
    let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
    let clocks = hal::clocks::init_clocks_and_plls(
        XTAL_FREQ_HZ,
        pac.XOSC,
        pac.CLOCKS,
        pac.PLL_SYS,
        pac.PLL_USB,
        &mut pac.RESETS,
        &mut watchdog,
    )
    .ok()
    .unwrap();

    let mut delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
    let timer = hal::Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
    let sio = hal::Sio::new(pac.SIO);
    let pins = hal::gpio::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);

    // I2C0 on GP4/GP5 with the RP2040's INTERNAL pull-ups (the Scroll Pack has no external ones).
    let sda: hal::gpio::Pin<_, hal::gpio::FunctionI2C, hal::gpio::PullUp> = pins.gpio4.reconfigure();
    let scl: hal::gpio::Pin<_, hal::gpio::FunctionI2C, hal::gpio::PullUp> = pins.gpio5.reconfigure();
    let i2c = hal::I2C::i2c0(
        pac.I2C0,
        sda,
        scl,
        400.kHz(),
        &mut pac.RESETS,
        &clocks.system_clock,
    );

    let mut mtx = Matrix::new(i2c, &mut delay);

    // --- USB-MIDI: the Scroll Pack has no speaker, so clicks play through the host (the editor's
    // "Device audio"). We send a GM note-on per lane hit on channel 10. ---
    let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
        pac.USBCTRL_REGS,
        pac.USBCTRL_DPRAM,
        clocks.usb_clock,
        true,
        &mut pac.RESETS,
    ));
    let mut midi = UsbMidiClass::new(&usb_bus, 1, 1).unwrap();
    let mut scsi = Scsi::new(&usb_bus, MSC_PACKET, MSC_MAX_LUN, unsafe {
        #[allow(static_mut_refs)]
        MSC_BUF.assume_init_mut()
    }
    .as_mut_slice())
    .unwrap();
    let mut scsi_state = ScsiState::new();
    let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x5e4))
        .strings(&[StringDescriptors::default()
            .manufacturer("VARASYS")
            .product("PM_G-1 Grid")
            .serial_number("PMG1")])
        .unwrap()
        .device_class(0)
        .build();
    info!("usb-midi configured (channel 10)");

    // boot splash (polls USB throughout so the host can enumerate during the animation)
    splash(&mut mtx, &mut delay, || {
        usb_dev.poll(&mut [&mut midi, &mut scsi]);
    });

    // buttons (active-low, internal pull-ups): A=GP12 B=GP13 X=GP14 Y=GP15
    let mut btn_a = pins.gpio12.into_pull_up_input();
    let mut btn_b = pins.gpio13.into_pull_up_input();
    let mut btn_x = pins.gpio14.into_pull_up_input();
    let mut btn_y = pins.gpio15.into_pull_up_input();

    let now_us = || timer.get_counter().ticks() as i64;
    // Read the drive's set lists (and (re)format it to "PM_G-1" if it isn't ours). Done AFTER the
    // splash so the screen shows life first and a FAT/flash problem can't leave it black.
    info!("fat: reading drive...");
    let user_setlists = read_user_setlists();
    info!("boot: {} total set list(s)", SETLISTS.len() + user_setlists.len());

    let mut app = App::new(now_us() * 1000, user_setlists);
    info!("groove: bpm={} lanes={}", app.tempo, app.track.lanes.len());

    // input edge/hold state
    let (mut pa, mut pb, mut px, mut py) = (false, false, false, false);
    let (mut press_a, mut press_b) = (0i64, 0i64);
    let (mut held_x, mut held_y) = (0i64, 0i64);
    let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64);
    let mut last_frame_us = 0i64;
    let mut hb_us = 0i64;
    let mut rxbuf = [0u8; 64];
    let mut fs_write_us = 0i64; // last host drive-write time
    let mut fs_pending = false; // a re-read of programs.json is owed once the drive goes idle

    loop {
        let us = now_us();
        let now_ns = us * 1000;

        // ---- USB: poll the composite device (MIDI + Mass Storage) ----
        if usb_dev.poll(&mut [&mut midi, &mut scsi]) {
            // service any pending SCSI command (drive read/write)
            let _ = scsi.poll(|cmd| {
                let _ = scsi_command(cmd, &mut scsi_state);
            });
        }
        // host wrote the drive → schedule a re-read once it's been idle for a bit
        if scsi_state.dirty {
            scsi_state.dirty = false;
            fs_write_us = us;
            fs_pending = true;
        }
        // re-read programs.json after the drive settles (host done writing) and we're stopped, so a
        // dropped file applies without a reboot. Read-only → no FAT-corruption risk.
        if fs_pending && !app.playing && us - fs_write_us > 1_500_000 {
            fs_pending = false;
            let user = read_programs_json();
            app.reload_user(user);
            info!("fat: re-read drive -> {} set list(s)", app.setlists.len());
        }
        // ---- drain the MIDI RX endpoint, feeding SysEx (live-sync) bytes ----
        while let Ok(n) = midi.read(&mut rxbuf) {
            if n == 0 {
                break;
            }
            let mut i = 0;
            while i + 4 <= n {
                // USB-MIDI event packet: low nibble = Code Index Number (how many data bytes)
                match rxbuf[i] & 0x0F {
                    0x4 | 0x7 => {
                        app.feed_midi(rxbuf[i + 1], now_ns);
                        app.feed_midi(rxbuf[i + 2], now_ns);
                        app.feed_midi(rxbuf[i + 3], now_ns);
                    }
                    0x6 => {
                        app.feed_midi(rxbuf[i + 1], now_ns);
                        app.feed_midi(rxbuf[i + 2], now_ns);
                    }
                    0x5 | 0xF => app.feed_midi(rxbuf[i + 1], now_ns), // single byte (SysEx end / realtime)
                    _ => {}                                           // channel-voice — ignored
                }
                i += 4;
            }
            if n < rxbuf.len() {
                break;
            }
        }

        // ---- inputs (active-low) ----
        let a = btn_a.is_low().unwrap_or(false);
        let b = btn_b.is_low().unwrap_or(false);
        let x = btn_x.is_low().unwrap_or(false);
        let y = btn_y.is_low().unwrap_or(false);

        // A: tap = play/stop, hold (>=600ms) = cycle view
        if a && !pa {
            press_a = us;
        }
        if !a && pa {
            if us - press_a >= 600_000 {
                app.cycle_view();
            } else {
                app.toggle(now_ns);
            }
        }
        // B: tap = next track, hold (>=600ms) = next set list
        if b && !pb {
            press_b = us;
        }
        if !b && pb {
            if us - press_b >= 600_000 {
                app.next_setlist(now_ns);
            } else {
                app.next_track(now_ns);
            }
        }
        // X: tempo up (tap +1, auto-repeat; +5 after 1.5s held)  [X/Y swapped per hardware layout]
        if x && !px {
            held_x = us;
            nextrep_x = us + 350_000;
            app.set_bpm(app.tempo + 1, now_ns);
        } else if x && px && us >= nextrep_x {
            nextrep_x = us + 120_000;
            let d = if us - held_x > 1_500_000 { 5 } else { 1 };
            app.set_bpm(app.tempo + d, now_ns);
        }
        // Y: tempo down
        if y && !py {
            held_y = us;
            nextrep_y = us + 350_000;
            app.set_bpm(app.tempo - 1, now_ns);
        } else if y && py && us >= nextrep_y {
            nextrep_y = us + 120_000;
            let d = if us - held_y > 1_500_000 { -5 } else { -1 };
            app.set_bpm(app.tempo + d, now_ns);
        }
        pa = a;
        pb = b;
        px = x;
        py = y;

        // ---- scheduler: advance clocks (queues note-ons onto app.tx_q) ----
        app.tick(now_ns);

        // ---- live-sync FULL heartbeat (~5s while a peer is connected) ----
        if app.sync_armed && now_ns >= app.sync_hb_next {
            app.sync_broadcast_full(now_ns);
        }

        // drain the USB-MIDI TX queue (notes + SysEx) to the endpoint: send until it's busy
        // (WouldBlock), keep the rest for the next poll. This is why chords play in full.
        while let Some(&pkt) = app.tx_q.front() {
            if midi.send_bytes(pkt).is_ok() {
                app.tx_q.pop_front();
            } else {
                break;
            }
        }

        // ---- ticker scroll advance (~120ms) ----
        // (uses the frame clock implicitly; scroll_off wraps mod scroll_total)
        let scroll_phase = (us / 120_000) as i32;
        app.scroll_off = scroll_phase.rem_euclid(app.scroll_total.max(1));

        // ---- render at ~33 fps ----
        if us - last_frame_us >= 30_000 {
            last_frame_us = us;
            render(&mut mtx, &app, now_ns);
        }

        // heartbeat (~1 Hz) for probe-rs/defmt debugging
        if us - hb_us > 1_000_000 {
            hb_us = us;
            info!(
                "alive: playing={} bpm={} step={} usb={} heap={}",
                app.playing,
                app.tempo,
                app.step.first().copied().unwrap_or(-1),
                usb_dev.state() as u8,
                HEAP.free()
            );
        }

        delay.delay_us(1000); // ~1 kHz loop: tight enough for USB polling + click timing
    }
}
