//! dsp.rs — Dual-CSS HF Mode
//! Chirp generation, dual-stream CSS modulator/demodulator, RS codec.
//!
//! Architecture (faithful port of dsp.py):
//!   Transmit:  text → RS encode → symbol list → CssModulator → audio
//!   Receive:   audio → dechirp (up + down) → joint_detect → RS decode → text
//!
//! Framing, normalisation and RS chunking follow the Python original, but the
//! chirps are cyclic (LoRa-style, wrapping at the band edges) and detection
//! uses analytic-signal dechirping — the Python app's linear chirps and real
//! dechirp never had a working receive path (see notes at `chirp_phase`).

use rustfft::num_complex::Complex;
use rustfft::FftPlanner;
use std::f64::consts::PI;

use crate::config::AppConfig;

// ── Packet state machine ─────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameState {
    Idle,
    L1Search,
    SyncWait,
    L2Pream,
    Header,
    Data,
    Decoded,
}

impl FrameState {
    /// Human-readable label, matching the Python enum values.
    pub fn label(&self) -> &'static str {
        match self {
            FrameState::Idle => "IDLE",
            FrameState::L1Search => "L1 SEARCH",
            FrameState::SyncWait => "SYNC WORD",
            FrameState::L2Pream => "L2 PREAMBLE",
            FrameState::Header => "HEADER",
            FrameState::Data => "DATA",
            FrameState::Decoded => "DECODED",
        }
    }
}

// ── Chirp primitives ─────────────────────────────────────────────────────────
//
// Chirps are *cyclic* (LoRa-style): the instantaneous frequency stays inside
// [freq_low, freq_low + bw] and wraps at the band edge. This keeps the whole
// transmission inside an HF SSB passband (the previous linear chirps swept as
// low as freq_low − bw and as high as freq_low + 2·bw) and — critically — makes
// dechirp detection unambiguous. With non-cyclic chirps the downchirp of a dual
// block mixed against the up reference produced a *constant* image tone at
// 2·freq_low − (s_inv/N)·bw (the chirp slopes cancel in the sum-frequency
// term), which at some spreading factors lands bin-centred and ties with the
// true tone (the sf=8 loopback failure).

/// Piecewise-quadratic phase (radians) of one cyclic CSS chirp symbol.
fn chirp_phase(sf: u32, bw: u32, fs: u32, freq_low: u32, symbol: u32, direction: i32) -> Vec<f64> {
    let n = (1u64 << sf) as f64;
    let bw = bw as f64;
    let fs = fs as f64;
    let fl = freq_low as f64;
    let t_dur = n / bw; // symbol duration in seconds
    let n_samples = (t_dur * fs).round() as usize;
    let k = bw / t_dur; // sweep-rate magnitude (Hz/s)
    let frac = (symbol as f64 % n) / n; // initial frequency offset fraction

    // Time at which the frequency offset wraps at the band edge.
    let t_wrap = if direction >= 0 {
        (1.0 - frac) * t_dur
    } else {
        frac * t_dur
    };

    let two_pi = 2.0 * std::f64::consts::PI;
    // Phase accumulated up to the wrap (integration constant for segment 2).
    let phase_at_wrap = if direction >= 0 {
        two_pi * ((fl + frac * bw) * t_wrap + 0.5 * k * t_wrap * t_wrap)
    } else {
        two_pi * ((fl + frac * bw) * t_wrap - 0.5 * k * t_wrap * t_wrap)
    };

    let mut out = Vec::with_capacity(n_samples);
    for i in 0..n_samples {
        // linspace(0, T, n_samples, endpoint=False) → t = i * T / n_samples
        let t = i as f64 * t_dur / n_samples as f64;
        let phase = if direction >= 0 {
            if t < t_wrap {
                // f: fl + frac·bw  →  fl + bw
                two_pi * ((fl + frac * bw) * t + 0.5 * k * t * t)
            } else {
                // wrapped: f restarts at fl
                let u = t - t_wrap;
                phase_at_wrap + two_pi * (fl * u + 0.5 * k * u * u)
            }
        } else if t < t_wrap {
            // f: fl + frac·bw  →  fl
            two_pi * ((fl + frac * bw) * t - 0.5 * k * t * t)
        } else {
            // wrapped: f restarts at fl + bw
            let u = t - t_wrap;
            phase_at_wrap + two_pi * ((fl + bw) * u - 0.5 * k * u * u)
        };
        out.push(phase);
    }
    out
}

/// Generate one CSS chirp symbol as f32 audio samples.
///
/// * `symbol`    — integer 0..(N-1), sets the cyclic start offset
/// * `direction` — +1 = upchirp, -1 = downchirp
pub fn make_chirp(
    sf: u32,
    bw: u32,
    fs: u32,
    freq_low: u32,
    symbol: u32,
    direction: i32,
) -> Vec<f32> {
    chirp_phase(sf, bw, fs, freq_low, symbol, direction)
        .into_iter()
        .map(|p| p.sin() as f32)
        .collect()
}

/// Complex-conjugate symbol-0 reference chirp, e^{−jφ(t)}, for dechirping.
pub fn make_conj_reference(
    sf: u32,
    bw: u32,
    fs: u32,
    freq_low: u32,
    direction: i32,
) -> Vec<Complex<f32>> {
    chirp_phase(sf, bw, fs, freq_low, 0, direction)
        .into_iter()
        .map(|p| Complex::new(p.cos() as f32, -p.sin() as f32))
        .collect()
}

/// Cached FFT plans for dechirp detection at one symbol length.
///
/// Detection pipeline: real block → analytic signal (FFT, zero the negative
/// frequencies, IFFT) → multiply by the conjugate reference chirp → FFT →
/// magnitude spectrum. Making the audio analytic removes every sum-frequency /
/// image product of real-signal mixing, so the spectrum contains only the true
/// beat tones of each chirp component.
pub struct Dechirper {
    len: usize,
    fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
    ifft: std::sync::Arc<dyn rustfft::Fft<f32>>,
}

impl Dechirper {
    pub fn new(len: usize) -> Self {
        let mut planner = FftPlanner::<f32>::new();
        Self {
            len: len.max(1),
            fft: planner.plan_fft_forward(len.max(1)),
            ifft: planner.plan_fft_inverse(len.max(1)),
        }
    }

    /// Magnitude spectrum of analytic(block) · conj_ref.
    fn spectrum(&self, block: &[f32], conj_ref: &[Complex<f32>]) -> Vec<f32> {
        let l = self.len;
        let mut buf: Vec<Complex<f32>> = block
            .iter()
            .take(l)
            .map(|&x| Complex::new(x, 0.0))
            .collect();
        buf.resize(l, Complex::new(0.0, 0.0));

        self.fft.process(&mut buf);
        for b in buf.iter_mut().skip(l / 2 + 1) {
            *b = Complex::new(0.0, 0.0); // analytic signal: drop negative freqs
        }
        self.ifft.process(&mut buf);

        for (b, r) in buf.iter_mut().zip(conj_ref.iter()) {
            *b *= *r;
        }
        self.fft.process(&mut buf);
        buf.iter().map(|c| c.norm()).collect()
    }

    /// Per-symbol detection scores (length `n`).
    ///
    /// A cyclic chirp's beat tone sits at bin `c` before its wrap and at the
    /// negative frequency `c − N` (FFT index L−N+c) after it; folding the two
    /// recovers the full symbol energy no matter where the wrap fell.
    pub fn fold_scores(&self, block: &[f32], conj_ref: &[Complex<f32>], n: usize) -> Vec<f32> {
        let spec = self.spectrum(block, conj_ref);
        let l = spec.len();
        let n = n.min(l);
        (0..n)
            .map(|c| {
                let mut s = spec[c];
                let neg = (c + l - n) % l;
                if neg >= n {
                    s += spec[neg];
                }
                s
            })
            .collect()
    }
}

/// Peak index and detection quality (peak over mean-excluding-top) of a
/// fold-score vector. Quality ≈ 1 for noise, large for a real chirp.
fn peak_and_quality(scores: &[f32]) -> (usize, f32) {
    let mut best_idx = 0usize;
    let mut best_val = f32::NEG_INFINITY;
    for (i, &v) in scores.iter().enumerate() {
        if v > best_val {
            best_val = v;
            best_idx = i;
        }
    }
    let noise = mean_excluding_top(scores, 4) + 1e-9;
    (best_idx, best_val / noise)
}

/// Joint dual-CSS detection: the block carries symbol `s` on the upchirp and
/// (N−s) mod N on the downchirp, so score each candidate as
/// up_score[c] + down_score[(N−c) mod N] and take the argmax.
fn detect_dual(up_scores: &[f32], down_scores: &[f32], n: usize) -> usize {
    let mut best_idx = 0usize;
    let mut best_val = f32::NEG_INFINITY;
    for c in 0..n.min(up_scores.len()) {
        let v = up_scores[c] + down_scores[(n - c) % n];
        if v > best_val {
            best_val = v;
            best_idx = c;
        }
    }
    best_idx
}

/// Detect a single chirp symbol in a sample-aligned block (test/loopback aid;
/// the live path uses the cached `Dechirper` inside `CssDemodulator`).
#[allow(dead_code)]
pub fn detect_symbol(block: &[f32], conj_ref: &[Complex<f32>], n: usize) -> usize {
    let dech = Dechirper::new(block.len());
    let (bin, _) = peak_and_quality(&dech.fold_scores(block, conj_ref, n));
    bin
}

// ── Frame header ─────────────────────────────────────────────────────────────
// A 16-bit header sits between the L2 preamble and the data symbols:
//   bits [15:14] — RS code-rate index (0=1/2, 1=1/3, 2=2/3)
//   bit  [13]    — turbo mode (down chirp carries an independent symbol)
//   bits [12:0]  — RS-encoded payload byte count
// Carrying the rate in the header means the receiver only has to detect SF and
// bandwidth (from the preamble); the coding rate and turbo flag are
// self-describing. (Byte counts are capped at MAX_FRAME_BYTES = 2048, so bit 13
// was always zero before it became the turbo flag — robust-mode frames are
// bit-identical with pre-turbo builds.) The header
// symbol sequence is transmitted TWICE; the receiver soft-combines the dechirp
// score vectors of both copies (~3 dB) before slicing, since a corrupted header
// loses the whole frame.

/// How many times the header symbol sequence is repeated on air.
pub const HEADER_REPEATS: usize = 2;

/// RS parity bytes for a code-rate index (matches `RsCodec::new`).
pub fn nsym_for_rate(code: u8) -> usize {
    match code {
        1 => 32, // 1/3
        2 => 8,  // 2/3
        _ => 16, // 1/2 (and reserved index 3)
    }
}

/// Code-rate index for the config string.
pub fn rate_code(rate: &str) -> u8 {
    match rate {
        "1/3" => 1,
        "2/3" => 2,
        _ => 0,
    }
}

/// Display label for a code-rate index.
pub fn rate_label(code: u8) -> &'static str {
    match code {
        1 => "1/3",
        2 => "2/3",
        _ => "1/2",
    }
}

/// Header size on air: the 16-bit word plus an 8-bit CRC. Without the CRC a
/// noise-corrupted header that happened to pass the range checks committed
/// the decoder to a bogus byte count, leaving it stuck in DATA collecting
/// noise for minutes (seen in the field as "Erasures 444, still decoding").
const HEADER_BITS: usize = 24;

/// Number of CSS symbols in ONE copy of the header at `sf` bits/symbol.
pub fn header_symbol_count(sf: u32) -> usize {
    let sf = sf.max(1) as usize;
    HEADER_BITS.div_ceil(sf)
}

/// Pack rate + turbo flag + byte count into the 16-bit header word.
fn header_word(rate_code: u8, byte_count: u16, turbo: bool) -> u16 {
    ((rate_code as u16 & 0x3) << 14) | ((turbo as u16) << 13) | (byte_count & 0x1FFF)
}

/// CRC-8 (poly 0x07) over the header word, MSB-first.
fn header_crc(word: u16) -> u8 {
    let mut crc = 0u8;
    for byte in word.to_be_bytes() {
        crc ^= byte;
        for _ in 0..8 {
            crc = if crc & 0x80 != 0 {
                (crc << 1) ^ 0x07
            } else {
                crc << 1
            };
        }
    }
    crc
}

/// Encode word+CRC into symbols (sf bits each, MSB-first, the final symbol
/// zero-padded in its low bits).
fn encode_header(rate_code: u8, byte_count: u16, turbo: bool, sf: u32) -> Vec<u32> {
    let word = header_word(rate_code, byte_count, turbo);
    let value = ((word as u32) << 8) | header_crc(word) as u32;
    let count = header_symbol_count(sf);
    let sf = sf as usize;
    let mut out = Vec::with_capacity(count);
    for i in 0..count {
        let mut sym = 0u32;
        for b in 0..sf {
            let bit_index = i * sf + b; // bit position counted from the MSB
            let bit = if bit_index < HEADER_BITS {
                (value >> (HEADER_BITS - 1 - bit_index)) & 1
            } else {
                0
            };
            sym = (sym << 1) | bit as u32;
        }
        out.push(sym);
    }
    out
}

/// Decode header symbols back into (rate_code, turbo, byte_count); `None`
/// when the CRC doesn't match (corrupted header or false lock — drop the
/// frame rather than trust a garbage length).
fn decode_header(syms: &[u32], sf: u32) -> Option<(u8, bool, u16)> {
    let sf = sf as usize;
    let mut value: u32 = 0;
    let mut taken = 0;
    'outer: for &s in syms {
        for b in 0..sf {
            if taken >= HEADER_BITS {
                break 'outer;
            }
            let bit = (s >> (sf - 1 - b)) & 1;
            value = (value << 1) | bit;
            taken += 1;
        }
    }
    let word = (value >> 8) as u16;
    if header_crc(word) != (value & 0xFF) as u8 {
        return None;
    }
    Some((
        (word >> 14) as u8 & 0x3,
        word & (1 << 13) != 0,
        word & 0x1FFF,
    ))
}

/// Per-symbol candidates tried by the soft header search.
const HEADER_CANDIDATES: usize = 3;

/// Soft header decode: instead of hard-slicing each symbol and rejecting the
/// frame on any single error, take the top few candidates of every symbol's
/// joint score vector and accept the highest-scoring combination whose CRC
/// matches and whose fields are sane (the byte count is the RS-encoded
/// length, so a real frame has at least nsym ecc bytes + 1 data byte). This
/// turns the CRC into a corrector for the dominant failure — one noisy
/// header symbol whose true bin came second.
fn decode_header_soft(per_sym: &[Vec<f32>], sf: u32) -> Option<(u8, bool, u16)> {
    let cands: Vec<Vec<(usize, f32)>> = per_sym
        .iter()
        .map(|j| {
            let mut idx: Vec<usize> = (0..j.len()).collect();
            idx.sort_unstable_by(|&a, &b| j[b].total_cmp(&j[a]));
            idx.into_iter()
                .take(HEADER_CANDIDATES)
                .map(|i| (i, j[i]))
                .collect()
        })
        .collect();

    // Enumerate every combination (≤ 3^4 = 81) in descending total-score
    // order so the strongest CRC-valid header wins.
    let mut combos: Vec<(f32, Vec<u32>)> = Vec::new();
    let total: usize = cands.iter().map(|c| c.len().max(1)).product();
    for mut ix in 0..total {
        let mut score = 0.0f32;
        let mut syms = Vec::with_capacity(cands.len());
        for c in &cands {
            let pick = ix % c.len().max(1);
            ix /= c.len().max(1);
            let (bin, s) = c.get(pick).copied().unwrap_or((0, 0.0));
            score += s;
            syms.push(bin as u32);
        }
        combos.push((score, syms));
    }
    combos.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));

    for (_, syms) in &combos {
        if let Some((rc, turbo, bc)) = decode_header(syms, sf) {
            if bc as usize > nsym_for_rate(rc) && bc <= MAX_FRAME_BYTES {
                return Some((rc, turbo, bc));
            }
        }
    }
    None
}

// ── Modulator ────────────────────────────────────────────────────────────────

/// Generates a complete dual-CSS packet as an f32 audio stream.
/// Robust encoding: upchirp carries symbol s, downchirp carries (N-s) mod N
/// (the same symbol twice, joined by a constraint the receiver can check).
/// Turbo encoding: the downchirp carries an independent payload symbol —
/// twice the data rate, no joint redundancy.
pub struct CssModulator {
    cfg: AppConfig,
}

impl CssModulator {
    pub fn new(cfg: AppConfig) -> Self {
        Self { cfg }
    }

    /// `payload_symbols`: each in range 0..(N-1). `rate_code`, `byte_count`
    /// and `turbo` go into the frame header so the receiver knows the coding
    /// rate, where the packet ends, and how the down stream is encoded.
    /// Returns mono f32 samples.
    pub fn modulate(
        &self,
        payload_symbols: &[u32],
        rate_code: u8,
        byte_count: u16,
        turbo: bool,
    ) -> Vec<f32> {
        let m = &self.cfg.modem;
        let fs = self.cfg.audio.sample_rate;
        let level = self.cfg.audio.output_level;
        let sf = m.spreading_factor;
        let bw = m.bandwidth();
        let fl = m.freq_low;
        let n = m.n_chips();

        // Dual-CSS block: symbol su on the up chirp, (N-sd)%N on the down.
        // Robust blocks use su == sd; turbo data blocks carry two symbols.
        let dual_block = |su: u32, sd: u32| -> Vec<f32> {
            let sd_inv = (n - (sd % n)) % n;
            let up = make_chirp(sf, bw, fs, fl, su, 1);
            let down = make_chirp(sf, bw, fs, fl, sd_inv, -1);
            sum_chirps(&up, &down)
        };

        let mut audio: Vec<f32> = Vec::new();

        // L1 preamble — full-BW upchirps
        for _ in 0..m.l1_preamble_symbols {
            audio.extend(make_chirp(sf, bw, fs, fl, 0, 1));
        }

        // Sync word — inverse (downchirp)
        for _ in 0..m.sync_symbols {
            audio.extend(make_chirp(sf, bw, fs, fl, 0, -1));
        }

        // L2 preamble — dual stream, symbol 0 on both
        for _ in 0..m.l2_preamble_symbols {
            let up = make_chirp(sf, bw, fs, fl, 0, 1);
            let down = make_chirp(sf, bw, fs, fl, 0, -1);
            audio.extend(sum_chirps(&up, &down));
        }

        // Frame header — rate + turbo + byte count as dual-CSS symbols,
        // repeated for soft combining at the receiver. Always robust-encoded:
        // the receiver must decode it before it knows the data-block mode.
        let hdr = encode_header(rate_code, byte_count, turbo, sf);
        for _ in 0..HEADER_REPEATS {
            for &s in &hdr {
                audio.extend(dual_block(s, s));
            }
        }

        // Data symbols. Robust: each symbol rides both chirps. Turbo: symbols
        // go out in pairs — even index up, odd index down — with a zero pad
        // on the down stream if the count is odd (the receiver truncates to
        // the header byte count, so the pad never reaches the payload).
        if turbo {
            for pair in payload_symbols.chunks(2) {
                audio.extend(dual_block(pair[0], pair.get(1).copied().unwrap_or(0)));
            }
        } else {
            for &s in payload_symbols {
                audio.extend(dual_block(s, s));
            }
        }

        // Normalise and apply output level (peak = max|x| + 1e-9)
        let peak = audio.iter().fold(0.0f32, |acc, &x| acc.max(x.abs())) + 1e-9;
        let scale = level / peak * 0.9;
        for x in audio.iter_mut() {
            *x *= scale;
        }
        audio
    }
}

/// Element-wise sum of two equal-length chirp buffers.
fn sum_chirps(a: &[f32], b: &[f32]) -> Vec<f32> {
    a.iter().zip(b.iter()).map(|(x, y)| x + y).collect()
}

// ── RX band-pass front end ───────────────────────────────────────────────────

/// Linear-phase FIR band-pass that confines the RX stream to the modem band
/// before dechirping. The dechirp itself is a matched filter, but it is not
/// band-selective: a tone up to one bandwidth *outside* the band still sweeps
/// straight through the correlation bins after dechirping (tone − reference
/// crosses 0..BW during the symbol), and broadband noise leaks in through
/// spectral sidelobes. Cutting everything outside the established band edges
/// removes that energy before it can raise the detector's noise floor.
///
/// Windowed-sinc (Blackman) design: ~74 dB stopband, ~150 Hz transition.
/// Linear phase means a constant group delay of (taps−1)/2 samples for every
/// frequency — the whole stream shifts uniformly, which preamble timing
/// acquisition absorbs like any other arrival offset.
#[derive(Clone)]
struct BandPass {
    taps: Vec<f32>,
    ring: Vec<f32>,
    pos: usize,
}

impl BandPass {
    /// Pass band `lo..hi` Hz at sample rate `fs`. A small guard is added by
    /// the caller so the cyclic chirps' wrap splatter isn't shaved off.
    fn new(fs: u32, lo: f64, hi: f64) -> Self {
        let fsf = fs as f64;
        let transition = 150.0; // Hz
        let ntaps = (((5.5 * fsf / transition).round() as usize) | 1).min(2047);
        let mid = (ntaps - 1) as f64 / 2.0;
        // Difference of two windowed-sinc low-passes = band-pass.
        let sinc = |f: f64, k: f64| -> f64 {
            let x = k - mid;
            if x.abs() < 1e-9 {
                2.0 * f / fsf
            } else {
                (2.0 * PI * f * x / fsf).sin() / (PI * x)
            }
        };
        let taps = (0..ntaps)
            .map(|k| {
                let kf = k as f64;
                let c = 2.0 * PI * kf / (ntaps - 1) as f64;
                let w = 0.42 - 0.5 * c.cos() + 0.08 * (2.0 * c).cos(); // Blackman
                ((sinc(hi, kf) - sinc(lo, kf)) * w) as f32
            })
            .collect::<Vec<_>>();
        Self {
            ring: vec![0.0; taps.len()],
            taps,
            pos: 0,
        }
    }

    /// Filter a chunk (stateful across calls; output length == input length).
    fn process(&mut self, input: &[f32]) -> Vec<f32> {
        let n = self.taps.len();
        let mut out = Vec::with_capacity(input.len());
        for &x in input {
            self.ring[self.pos] = x;
            self.pos += 1;
            if self.pos == n {
                self.pos = 0;
            }
            // Symmetric (linear-phase) taps, so sample order within the dot
            // product doesn't matter — two straight slices vectorise well.
            let (newer, older) = self.ring.split_at(self.pos);
            let mut acc = 0.0f32;
            for (t, s) in self.taps[..older.len()].iter().zip(older) {
                acc += t * s;
            }
            for (t, s) in self.taps[older.len()..].iter().zip(newer) {
                acc += t * s;
            }
            out.push(acc);
        }
        out
    }
}

/// Guard added on each side of the modem band before filtering, Hz.
fn band_guard(bw: u32) -> f64 {
    (0.04 * bw as f64).max(40.0)
}

// ── RX spectral whitener ─────────────────────────────────────────────────────

/// STFT length of the whitener (≈ 47 Hz bins, ~21 ms frames at 48 kHz).
const WHITEN_FFT: usize = 1024;
const WHITEN_HOP: usize = WHITEN_FFT / 2;
const WHITEN_BINS: usize = WHITEN_FFT / 2 + 1;
/// Noise-floor rise rate per frame. Deliberately slow: a chirp dwells on any
/// one bin ≤ ~10 % of the time (even at SF-12 / 500 Hz), so a multi-second
/// attack keeps the signal itself from pulling the floor up, while a carrier
/// that sits there for seconds is fully learned.
const WHITEN_ATTACK: f32 = 0.002;
/// Noise-floor fall rate per frame — vacated bins recover in ~0.2 s.
const WHITEN_DECAY: f32 = 0.05;

/// Spectral whitener: learns the long-term noise floor of every frequency bin
/// and grades each bin against it, so persistent in-band interference (a
/// carrier, a heterodyne, power-line buzz) is flattened down to the level of
/// the noise around it before dechirping. The dechirp matched filter is only
/// optimal in *white* noise; a steady tone inside the band sweeps through the
/// fold bins after dechirping, and the band-pass can only remove interference
/// *outside* the band — this stage handles the inside.
///
/// A chirp itself is immune to the learned floor: it never lingers at one
/// frequency, and because the cyclic chirps sweep the band uniformly, what
/// little floor they raise is flat across the band — a uniform gain change
/// that the ratio-based detectors ignore.
///
/// Resynthesis is weighted overlap-add: √Hann analysis and synthesis windows
/// at 50 % overlap sum to exactly 1, so with unity gains the input is
/// reproduced bit-faithfully one hop late (absorbed by acquisition like any
/// other arrival offset).
#[derive(Clone)]
struct Whitener {
    fft: std::sync::Arc<dyn rustfft::Fft<f32>>,
    ifft: std::sync::Arc<dyn rustfft::Fft<f32>>,
    window: Vec<f32>, // √Hann (periodic), WHITEN_FFT long
    floor: Vec<f32>,  // per-bin noise-floor estimate
    primed: bool,     // floor seeded from the first frame?
    /// Floor learning enabled. Frozen by the demodulator while a frame is in
    /// progress: at the narrow bandwidth presets a chirp dwells long enough
    /// in each ~47 Hz bin (~50 % duty at 100 Hz) that a multi-second frame
    /// would otherwise pull the floor up under the very signal being decoded.
    learn: bool,
    inbuf: Vec<f32>, // input awaiting a full hop
    hist: Vec<f32>,  // last WHITEN_FFT input samples
    ola: Vec<f32>,   // overlap-add accumulator
}

impl Whitener {
    fn new() -> Self {
        let mut planner = FftPlanner::<f32>::new();
        let window = (0..WHITEN_FFT)
            .map(|i| {
                let h = 0.5 - 0.5 * (2.0 * PI * i as f64 / WHITEN_FFT as f64).cos();
                h.sqrt() as f32
            })
            .collect();
        Self {
            fft: planner.plan_fft_forward(WHITEN_FFT),
            ifft: planner.plan_fft_inverse(WHITEN_FFT),
            window,
            floor: vec![0.0; WHITEN_BINS],
            primed: false,
            learn: true,
            inbuf: Vec::new(),
            hist: vec![0.0; WHITEN_FFT],
            ola: vec![0.0; WHITEN_FFT],
        }
    }

    /// Whiten a chunk (stateful across calls). Output is delivered in
    /// hop-sized steps, one hop behind the input.
    fn process(&mut self, input: &[f32]) -> Vec<f32> {
        self.inbuf.extend_from_slice(input);
        let mut out = Vec::with_capacity(self.inbuf.len());
        while self.inbuf.len() >= WHITEN_HOP {
            self.hist.drain(..WHITEN_HOP);
            self.hist.extend(self.inbuf.drain(..WHITEN_HOP));
            let mut buf: Vec<Complex<f32>> = self
                .hist
                .iter()
                .zip(&self.window)
                .map(|(&x, &w)| Complex::new(x * w, 0.0))
                .collect();
            self.fft.process(&mut buf);

            let mut mean = 0.0f32;
            for (k, f) in self.floor.iter_mut().enumerate() {
                let mag = buf[k].norm();
                if !self.primed {
                    *f = mag; // seed regardless of `learn` — a zero floor would max-boost everything
                } else if self.learn {
                    if mag > *f {
                        *f += WHITEN_ATTACK * (mag - *f);
                    } else {
                        *f += WHITEN_DECAY * (mag - *f);
                    }
                }
                mean += *f;
            }
            self.primed = true;
            mean /= WHITEN_BINS as f32;

            // Grade on the curve: a bin sitting at the average floor passes at
            // unity, a bin that sticks out is cut by how far it sticks out,
            // a quiet bin is boosted (capped ~11×) and digital silence
            // degenerates to unity gain. Smoothing over 3 bins avoids sharp
            // gain steps (frame-edge leakage) without widening a carrier
            // notch meaningfully.
            let eps = 0.1 * mean + 1e-7;
            let gain = |k: usize| (mean + eps) / (self.floor[k] + eps);
            let scale = 1.0 / WHITEN_FFT as f32; // rustfft inverse is unnormalised
            for k in 0..WHITEN_BINS {
                let g = scale
                    * (gain(k.saturating_sub(1)) + gain(k) + gain((k + 1).min(WHITEN_BINS - 1)))
                    / 3.0;
                buf[k] *= g;
                if k > 0 && k < WHITEN_FFT - k {
                    buf[WHITEN_FFT - k] *= g; // mirror bin of the real signal
                }
            }
            self.ifft.process(&mut buf);
            for ((o, b), w) in self.ola.iter_mut().zip(&buf).zip(&self.window) {
                *o += b.re * w;
            }
            out.extend(self.ola.drain(..WHITEN_HOP));
            self.ola.resize(WHITEN_FFT, 0.0);
        }
        out
    }

    fn set_learning(&mut self, on: bool) {
        self.learn = on;
    }

    /// The learned per-bin noise floor (magnitudes), or `None` before the
    /// first frame has seeded it. Bin k covers k·fs/WHITEN_FFT Hz.
    fn floor_snapshot(&self) -> Option<&[f32]> {
        self.primed.then_some(&self.floor[..])
    }
}

/// Mean learned floor over `lo..hi` Hz (magnitude units).
fn band_floor_mean(floor: &[f32], fs: u32, lo: f64, hi: f64) -> f32 {
    let bin_hz = fs as f64 / WHITEN_FFT as f64;
    let k0 = (lo / bin_hz).floor().max(0.0) as usize;
    let k1 = ((hi / bin_hz).ceil() as usize).min(floor.len().saturating_sub(1));
    if k1 <= k0 {
        return 0.0;
    }
    floor[k0..=k1].iter().sum::<f32>() / (k1 - k0 + 1) as f32
}

/// Slide a `bw`-wide window across the 300–3000 Hz passband and return the
/// centre frequency (rounded to 10 Hz) with the lowest mean learned noise
/// floor, plus that mean — the quietest place to park the modem band.
pub fn quietest_band(floor: &[f32], fs: u32, bw: u32) -> Option<(u32, f32)> {
    use crate::config::{BAND_MAX_HZ, BAND_MIN_HZ};
    let bw = bw.clamp(50, BAND_MAX_HZ - BAND_MIN_HZ);
    let half = bw / 2;
    let (c_lo, c_hi) = (BAND_MIN_HZ + half, BAND_MAX_HZ - (bw - half));
    if c_hi < c_lo || floor.is_empty() {
        return None;
    }
    let bin_hz = fs as f64 / WHITEN_FFT as f64;
    let mut best: Option<(u32, f32)> = None;
    let mut c = c_lo as f64;
    while c <= c_hi as f64 {
        let m = band_floor_mean(floor, fs, c - half as f64, c + (bw - half) as f64);
        if best.is_none_or(|(_, b)| m < b) {
            best = Some(((c / 10.0).round() as u32 * 10, m));
        }
        c += bin_hz;
    }
    best
}

// ── Demodulator ──────────────────────────────────────────────────────────────

#[derive(Debug, Clone)]
pub struct DemodStatus {
    pub state: FrameState,
    pub snr_db: f32,
    pub delta_locked: bool,
    pub erasure_count: i32,
    pub error_count: i32,
    pub symbols_rx: i32,
}

impl Default for DemodStatus {
    fn default() -> Self {
        Self {
            state: FrameState::Idle,
            snr_db: -99.0,
            delta_locked: false,
            erasure_count: 0,
            error_count: 0,
            symbols_rx: 0,
        }
    }
}

/// Detection quality (peak / mean-excluding-top of the fold scores) required
/// to treat a block as containing a chirp. Noise-only blocks score ≈ 2–4;
/// a real chirp scores in the tens to hundreds. Kept low for weak signals —
/// false acquisitions are rejected by the two-hit + confirm sequence and the
/// header sanity check.
const DETECT_QUALITY: f32 = 6.0;

/// Consecutive weak blocks tolerated while locked on the preamble before the
/// lock is dropped (rides through a noise burst or a fade).
const LOCK_MISS_LIMIT: u32 = 2;

/// Turbo-mode per-stream reliability threshold (dechirp peak / noise mean).
/// Turbo data blocks have no joint constraint to flag bad symbols, so a
/// stream whose tone quality falls below this marks its symbol as an RS
/// erasure. Lower than DETECT_QUALITY: marginal-but-correct symbols shouldn't
/// burn erasure budget (and rs_decode retries without hints anyway).
const TURBO_ERASE_QUALITY: f32 = 4.0;

/// Largest RS-encoded payload the header sanity check will accept.
const MAX_FRAME_BYTES: u16 = 2048;

/// Largest residual frequency offset (in detection bins) accepted at the sync
/// word. Larger offsets are treated as a false acquisition.
const MAX_CFO_BINS: f64 = 12.0;

/// One frame recovered by the live demodulator, with everything needed to
/// RS-decode it and report where it came from.
#[derive(Debug, Clone)]
pub struct RxFrame {
    pub symbols: Vec<u32>,
    /// Joint-constraint failure per data symbol — fed to the RS decoder as
    /// byte erasures (RS corrects twice as many erasures as errors).
    pub bad_symbols: Vec<bool>,
    pub byte_count: u16,
    pub rate_code: u8,
    /// Frame arrived in turbo mode (independent down-stream symbols).
    pub turbo: bool,
    pub sf: u32,
    pub bw: u32,
    pub snr_db: f32,
}

/// State-machine demodulator. Call `feed(samples)` as audio blocks arrive;
/// it returns a status snapshot for each complete symbol processed (mirroring
/// the Python `status_updated` signal emitted per block).
///
/// Frame acquisition: the L1 preamble (repeated symbol-0 upchirps) is detected
/// by its dechirp tone; because the preamble is periodic, an arbitrary block
/// offset δ shows up as a tone at bin round(δ·N/L), which directly gives the
/// timing correction. After realignment the sync word (downchirps), L2
/// preamble, length header and data symbols are consumed by count.
pub struct CssDemodulator {
    cfg: AppConfig,
    state: FrameState,
    status: DemodStatus,
    buf: Vec<f32>,
    conj_up: Vec<Complex<f32>>,
    conj_down: Vec<Complex<f32>>,
    dech: Dechirper,
    sym_len: usize,
    n: usize,
    // Acquisition / frame bookkeeping
    pending_skip: usize,    // samples to discard for timing realignment
    l1_bin: Option<usize>,  // peak bin of the previous L1 candidate block
    awaiting_confirm: bool, // realignment applied, next block must peak at 0
    weak_count: u32,        // consecutive weak blocks while locked (hysteresis)
    blocks_left: u32,       // countdown within SyncWait / L2Pream
    /// Residual frequency offset measured on the sync downchirps, in bins.
    /// After L1 timing acquisition the up-stream tones land at their true
    /// bins, but the down-stream tones are shifted by this amount — header
    /// and data detection compensate for it.
    down_shift: i64,
    /// Fractional CFO measurements accumulated over the sync downchirps;
    /// averaged into `down_shift` when the L2 preamble begins.
    cfo_sum: f64,
    cfo_blocks: u32,
    /// Per-block (up, down) fold-score vectors of the header copies, kept for
    /// soft combining across HEADER_REPEATS.
    hdr_scores: Vec<(Vec<f32>, Vec<f32>)>,
    data_syms: Vec<u32>,
    bad_syms: Vec<bool>,
    byte_count: u16,
    rate_code: u8,
    /// Set from the frame header: data blocks carry two independent symbols
    /// (up + down) instead of one joint-coded symbol.
    turbo: bool,
    data_wanted: usize,
    /// Consecutive joint-constraint failures in DATA. A run longer than RS
    /// could ever repair means the signal is gone (TX ended early, fade, or
    /// the lock was false) — abort instead of decoding noise to the bitter end.
    bad_streak: u32,
    /// Symbol-timing loop: receive/transmit sample clocks differ by some ppm,
    /// so over a long frame the block boundaries creep relative to the TX
    /// chirps and the dechirp tones migrate off bin center (silently
    /// mis-slicing symbols — the joint constraint can't see it because timing
    /// shifts the up and down tones by opposite amounts that cancel in the
    /// sum). The fractional tone deviations measure the residual,
    /// (dev_up − dev_down)/2 bins; `drift_f` smooths it and, once it maps to
    /// ≥ 2 samples, the block boundary is trimmed: `rewind` re-uses that many
    /// tail samples (windows running late), `pending_skip` drops them
    /// (windows running early).
    drift_f: f64,
    rewind: usize,
    /// Band-pass front end confining the stream to the modem band (+guard).
    filter: BandPass,
    /// Spectral whitener flattening persistent in-band interference; runs
    /// ahead of the band-pass.
    whitener: Whitener,
    frames: Vec<RxFrame>, // completed frames awaiting RS decode
}

impl CssDemodulator {
    pub fn new(cfg: AppConfig) -> Self {
        let mut d = Self {
            cfg,
            state: FrameState::Idle,
            status: DemodStatus::default(),
            buf: Vec::new(),
            conj_up: Vec::new(),
            conj_down: Vec::new(),
            dech: Dechirper::new(1),
            sym_len: 0,
            n: 0,
            pending_skip: 0,
            l1_bin: None,
            awaiting_confirm: false,
            weak_count: 0,
            blocks_left: 0,
            down_shift: 0,
            cfo_sum: 0.0,
            cfo_blocks: 0,
            hdr_scores: Vec::new(),
            data_syms: Vec::new(),
            bad_syms: Vec::new(),
            byte_count: 0,
            rate_code: 0,
            turbo: false,
            data_wanted: 0,
            bad_streak: 0,
            drift_f: 0.0,
            rewind: 0,
            filter: BandPass::new(48000, 300.0, 3000.0), // rebuilt below
            whitener: Whitener::new(),
            frames: Vec::new(),
        };
        d.rebuild_references();
        d
    }

    fn rebuild_references(&mut self) {
        let m = &self.cfg.modem;
        let fs = self.cfg.audio.sample_rate;
        self.conj_up = make_conj_reference(m.spreading_factor, m.bandwidth(), fs, m.freq_low, 1);
        self.conj_down = make_conj_reference(m.spreading_factor, m.bandwidth(), fs, m.freq_low, -1);
        self.sym_len = self.conj_up.len();
        self.n = m.n_chips() as usize;
        self.dech = Dechirper::new(self.sym_len);
        let g = band_guard(m.bandwidth());
        self.filter = BandPass::new(
            fs,
            m.freq_low as f64 - g,
            (m.freq_low + m.bandwidth()) as f64 + g,
        );
        self.reset_acquisition();
        self.buf.clear();
        self.state = FrameState::Idle;
    }

    /// Drop all frame-in-progress state and return to scanning.
    fn reset_acquisition(&mut self) {
        self.pending_skip = 0;
        self.l1_bin = None;
        self.awaiting_confirm = false;
        self.weak_count = 0;
        self.blocks_left = 0;
        self.down_shift = 0;
        self.cfo_sum = 0.0;
        self.cfo_blocks = 0;
        self.hdr_scores.clear();
        self.data_syms.clear();
        self.bad_syms.clear();
        self.byte_count = 0;
        self.rate_code = 0;
        self.turbo = false;
        self.data_wanted = 0;
        self.bad_streak = 0;
        self.drift_f = 0.0;
        self.rewind = 0;
        self.status.delta_locked = false;
    }

    /// Hand over acquisition from an auto-detect scanner: adopt the scanner's
    /// remaining buffered samples and its timing estimate, so the demodulator
    /// only needs the confirm block instead of redoing the two-hit search
    /// (preamble symbols are a scarce budget).
    pub fn seed_acquisition(&mut self, samples: &[f32], skip: usize) {
        self.reset_acquisition();
        self.buf = samples.to_vec();
        self.pending_skip = skip;
        self.awaiting_confirm = true;
        self.state = FrameState::L1Search;
    }

    /// Continue with a scanner's whitener + band-pass state so the seeded
    /// (already processed) buffer and the samples that follow form one
    /// seamless stream.
    fn adopt_front_end(&mut self, whitener: Whitener, filter: BandPass) {
        self.whitener = whitener;
        self.filter = filter;
    }

    /// Append new samples and process any complete symbols in the buffer.
    pub fn feed(&mut self, samples: &[f32]) -> Vec<DemodStatus> {
        // Freeze noise-floor learning while a frame is in progress — see the
        // `learn` field. (One-feed lag on state changes is ~2 whitener frames
        // at attack 0.002: negligible.)
        self.whitener
            .set_learning(matches!(self.state, FrameState::Idle));
        let white = self.whitener.process(samples);
        let filtered = self.filter.process(&white);
        self.buf.extend_from_slice(&filtered);
        let mut updates = Vec::new();

        loop {
            if self.pending_skip > 0 {
                let take = self.pending_skip.min(self.buf.len());
                self.buf.drain(..take);
                self.pending_skip -= take;
                if self.pending_skip > 0 {
                    break; // need more samples before realignment completes
                }
            }
            if self.sym_len == 0 || self.buf.len() < self.sym_len {
                break;
            }
            let block: Vec<f32> = self.buf[..self.sym_len].to_vec();
            // Clock-drift trim: re-use the last `rewind` samples so the next
            // window starts that much earlier (RX windows creeping late).
            let drain = self.sym_len - self.rewind.min(self.sym_len / 2);
            self.rewind = 0;
            self.buf.drain(..drain);
            updates.push(self.process_block(&block));
        }
        updates
    }

    fn process_block(&mut self, block: &[f32]) -> DemodStatus {
        let n = self.n;
        let l = self.sym_len;

        // A frame finished on the previous block: show DECODED for one update,
        // then drop back to scanning.
        if self.state == FrameState::Decoded {
            self.state = FrameState::Idle;
            self.reset_acquisition();
        }

        let up = self.dech.fold_scores(block, &self.conj_up, n);
        let down = self.dech.fold_scores(block, &self.conj_down, n);
        let (peak_up, q_up) = peak_and_quality(&up);
        let (peak_down, q_down) = peak_and_quality(&down);

        // SNR estimate from the stronger stream.
        let (peak_val, scores) = if q_up >= q_down {
            (up[peak_up], &up)
        } else {
            (down[peak_down], &down)
        };
        let noise = mean_excluding_top(scores, 8) + 1e-9;
        self.status.snr_db = 10.0 * (peak_val / noise).log10();

        // Signed fractional position of the down peak (cyclic, bins from 0).
        let down_signed = {
            let p = interp_peak(&down, peak_down, n);
            if p > n as f64 / 2.0 {
                p - n as f64
            } else {
                p
            }
        };

        match self.state {
            FrameState::Idle | FrameState::L1Search => {
                if self.awaiting_confirm {
                    // After realignment the upchirp tone must sit near bin 0.
                    self.awaiting_confirm = false;
                    let near_zero = peak_up <= 2 || peak_up >= n - 2;
                    if q_up >= DETECT_QUALITY && near_zero {
                        // Fine trim: measure the residual fractional offset and
                        // correct once if it exceeds a few samples. A positive
                        // residual means we start late, so realigning costs one
                        // preamble chirp (skip l − r); a negative one is a
                        // cheap |r|-sample skip.
                        let p_frac = interp_peak(&up, peak_up, n);
                        let signed = if p_frac > n as f64 / 2.0 {
                            p_frac - n as f64
                        } else {
                            p_frac
                        };
                        let r = (signed * l as f64 / n as f64).round() as i64;
                        if r > 3 {
                            self.pending_skip = l - r as usize;
                        } else if r < -3 {
                            self.pending_skip = (-r) as usize;
                        }
                        self.status.delta_locked = true;
                        self.state = FrameState::L1Search;
                    } else {
                        self.reset_acquisition();
                        self.state = FrameState::Idle;
                    }
                } else if self.status.delta_locked {
                    // Locked on the preamble: wait for the sync downchirps.
                    // The sync peak's offset from bin 0 measures the residual
                    // frequency error (the L1 acquisition absorbed it into
                    // timing, which doubles it on the down stream). No
                    // up-energy comparison here: on an acoustic path the
                    // reverb tail of the preamble upchirps keeps q_up high
                    // through the first downchirp, and triggering one block
                    // late used to shift every later symbol window.
                    if q_down >= DETECT_QUALITY && down_signed.abs() <= MAX_CFO_BINS {
                        self.down_shift = down_signed.round() as i64;
                        self.cfo_sum = down_signed;
                        self.cfo_blocks = 1;
                        self.weak_count = 0;
                        self.state = FrameState::SyncWait;
                        // Upper bound on blocks spent waiting for the L2
                        // transition, not an exact count — alignment comes
                        // from spotting the up tone's return, so a faded
                        // sync chirp can't shift the frame.
                        self.blocks_left = self.cfg.modem.sync_symbols + 4;
                    } else if q_up >= DETECT_QUALITY {
                        self.weak_count = 0; // still on the preamble
                    } else {
                        // Hysteresis: ride through a short fade before
                        // declaring the signal gone.
                        self.weak_count += 1;
                        if self.weak_count > LOCK_MISS_LIMIT {
                            self.reset_acquisition();
                            self.state = FrameState::Idle;
                        }
                    }
                } else if q_up >= DETECT_QUALITY && q_up >= q_down {
                    self.state = FrameState::L1Search;
                    match self.l1_bin {
                        Some(prev) if bins_close(prev, peak_up, n) => {
                            // Two consecutive blocks agree → timing estimate.
                            // Tone at bin p ⇒ block starts p·L/N after a chirp
                            // boundary ⇒ skip L − p·L/N to land on the next one.
                            // Fractional interpolation keeps the residual well
                            // under a bin (~L/N samples).
                            let p_frac = interp_peak(&up, peak_up, n);
                            let delta = ((p_frac * l as f64 / n as f64).round() as i64)
                                .rem_euclid(l as i64)
                                as usize;
                            if delta != 0 {
                                self.pending_skip = l - delta;
                            }
                            self.l1_bin = None;
                            self.awaiting_confirm = true;
                        }
                        _ => self.l1_bin = Some(peak_up),
                    }
                } else {
                    self.l1_bin = None;
                    self.state = FrameState::Idle;
                }
            }
            FrameState::SyncWait => {
                // The sync downchirps end when the dual L2 preamble brings
                // the up tone back to bin 0 — detect that transition instead
                // of counting blocks, so a faded or late-detected sync chirp
                // cannot shift every later symbol window (which garbled the
                // header). Each clean downchirp meanwhile refines the CFO.
                let near_zero = peak_up <= 2 || peak_up >= n - 2;
                if q_up >= DETECT_QUALITY && near_zero {
                    // First L2 block: lock the averaged frequency shift and
                    // count off the remaining L2 chirps.
                    self.down_shift = (self.cfo_sum / self.cfo_blocks.max(1) as f64).round() as i64;
                    if self.joint_ok(peak_up, peak_down) {
                        self.track_drift(&up, peak_up, &down, peak_down);
                    }
                    self.blocks_left = self.cfg.modem.l2_preamble_symbols.saturating_sub(1);
                    if self.blocks_left == 0 {
                        self.state = FrameState::Header;
                        self.hdr_scores.clear();
                    } else {
                        self.state = FrameState::L2Pream;
                    }
                } else {
                    if q_down >= DETECT_QUALITY && down_signed.abs() <= MAX_CFO_BINS {
                        self.cfo_sum += down_signed;
                        self.cfo_blocks += 1;
                    }
                    self.blocks_left = self.blocks_left.saturating_sub(1);
                    if self.blocks_left == 0 {
                        self.reset_acquisition();
                        self.state = FrameState::Idle;
                    }
                }
            }
            FrameState::L2Pream => {
                // Known dual blocks (symbol 0 on both streams): use them to
                // keep the timing loop fresh before the header arrives.
                if q_up >= DETECT_QUALITY && self.joint_ok(peak_up, peak_down) {
                    self.track_drift(&up, peak_up, &down, peak_down);
                }
                self.blocks_left = self.blocks_left.saturating_sub(1);
                if self.blocks_left == 0 {
                    self.state = FrameState::Header;
                    self.hdr_scores.clear();
                }
            }
            FrameState::Header => {
                let sf = self.cfg.modem.spreading_factor;
                if self.tally_joint(peak_up, peak_down) {
                    self.track_drift(&up, peak_up, &down, peak_down);
                }
                self.hdr_scores.push((up.clone(), down.clone()));
                let hdr_count = header_symbol_count(sf);
                if self.hdr_scores.len() >= hdr_count * HEADER_REPEATS {
                    // Soft-combine the score vectors of all header copies —
                    // a chirp wiped out in one copy still contributes from
                    // the other — then search the per-symbol candidates for
                    // a CRC-valid header instead of hard-slicing.
                    let mut per_sym = Vec::with_capacity(hdr_count);
                    for i in 0..hdr_count {
                        let mut su = self.hdr_scores[i].0.clone();
                        let mut sd = self.hdr_scores[i].1.clone();
                        for r in 1..HEADER_REPEATS {
                            let (u2, d2) = &self.hdr_scores[i + r * hdr_count];
                            for (a, b) in su.iter_mut().zip(u2.iter()) {
                                *a += *b;
                            }
                            for (a, b) in sd.iter_mut().zip(d2.iter()) {
                                *a += *b;
                            }
                        }
                        per_sym.push(self.joint_header_scores(&su, &sd));
                    }
                    match decode_header_soft(&per_sym, sf) {
                        Some((rc, turbo, bc)) => {
                            self.rate_code = rc;
                            self.turbo = turbo;
                            self.byte_count = bc;
                            self.data_wanted = (bc as usize * 8).div_ceil(sf as usize);
                            self.data_syms.clear();
                            self.bad_syms.clear();
                            self.bad_streak = 0;
                            self.state = FrameState::Data;
                        }
                        None => {
                            self.reset_acquisition();
                            self.state = FrameState::Idle;
                        }
                    }
                }
            }
            FrameState::Data => {
                if self.turbo {
                    // Independent streams: the up tone is the even-index
                    // symbol directly; the down tone maps back through the
                    // same (N−s)+CFO relation the joint detector uses. No
                    // joint constraint exists, so per-stream tone quality
                    // stands in for the erasure flag.
                    let s_up = peak_up as u32;
                    let s_down =
                        (self.down_shift - peak_down as i64).rem_euclid(n as i64) as u32;
                    let up_ok = q_up >= TURBO_ERASE_QUALITY;
                    let down_ok = q_down >= TURBO_ERASE_QUALITY;
                    self.tally_stream(up_ok);
                    self.tally_stream(down_ok);
                    if up_ok && down_ok {
                        self.track_drift(&up, peak_up, &down, peak_down);
                    }
                    if up_ok || down_ok {
                        self.bad_streak = 0;
                    } else {
                        self.bad_streak += 2; // two symbols lost per dead block
                    }
                    self.data_syms.push(s_up);
                    self.bad_syms.push(!up_ok);
                    if self.data_syms.len() < self.data_wanted {
                        self.data_syms.push(s_down);
                        self.bad_syms.push(!down_ok);
                    }
                } else {
                    let sym = self.detect_dual_shifted(&up, &down) as u32;
                    let valid = self.tally_joint(peak_up, peak_down);
                    if valid {
                        self.bad_streak = 0;
                        self.track_drift(&up, peak_up, &down, peak_down);
                    } else {
                        self.bad_streak += 1;
                    }
                    self.data_syms.push(sym);
                    self.bad_syms.push(!valid);
                }
                // A contiguous bad run spanning more than ~2 chunks' worth
                // of ecc bytes is beyond erasure repair no matter what
                // follows — the signal is gone, stop collecting noise.
                // (`bad_streak` counts symbols, so the limit is mode-agnostic.)
                let sf = self.cfg.modem.spreading_factor as usize;
                let limit = 2 * ((nsym_for_rate(self.rate_code) * 8).div_ceil(sf) as u32) + 4;
                if self.bad_streak > limit {
                    self.reset_acquisition();
                    self.state = FrameState::Idle;
                    self.status.state = self.state;
                    return self.status.clone();
                }
                if self.data_syms.len() >= self.data_wanted {
                    self.frames.push(RxFrame {
                        symbols: std::mem::take(&mut self.data_syms),
                        bad_symbols: std::mem::take(&mut self.bad_syms),
                        byte_count: self.byte_count,
                        rate_code: self.rate_code,
                        turbo: self.turbo,
                        sf: self.cfg.modem.spreading_factor,
                        bw: self.cfg.modem.bandwidth(),
                        snr_db: self.status.snr_db,
                    });
                    self.state = FrameState::Decoded;
                }
            }
            FrameState::Decoded => {} // handled at top of fn
        }

        self.status.state = self.state;
        self.status.clone()
    }

    /// Joint dual-CSS detection with the measured frequency shift applied to
    /// the down stream (the up stream is shift-free after L1 acquisition).
    fn detect_dual_shifted(&self, up_scores: &[f32], down_scores: &[f32]) -> usize {
        let n = self.n;
        let shift = self.down_shift.rem_euclid(n as i64) as usize;
        let mut best_idx = 0usize;
        let mut best_val = f32::NEG_INFINITY;
        for c in 0..n.min(up_scores.len()) {
            let v = up_scores[c] + down_scores[((n - c) % n + shift) % n];
            if v > best_val {
                best_val = v;
                best_idx = c;
            }
        }
        best_idx
    }

    /// Symbol-timing loop tracking slow drift between the TX and RX sample
    /// clocks. A timing error of τ bins moves the up tone by +τ and the down
    /// tone by −τ, so half the difference of their fractional peak deviations
    /// measures τ independently of the symbol values (CFO is common-mode and
    /// cancels). Long frames need this: 100 ppm of clock offset walks the
    /// tones across several bins before the frame ends, mis-slicing symbols
    /// in a way the joint constraint cannot see (the ±τ cancels in its sum).
    /// The correction is applied in the sample domain — trimming the next
    /// block boundary keeps the tones at bin center, avoiding the adjacent-
    /// bin energy splits a bin-domain correction would suffer near half-bin
    /// residuals. Only joint-consistent blocks update the loop.
    fn track_drift(&mut self, up: &[f32], peak_up: usize, down: &[f32], peak_down: usize) {
        let n = self.n as f64;
        let dev = |scores: &[f32], peak: usize| -> f64 {
            let mut d = interp_peak(scores, peak, self.n) - peak as f64;
            d -= (d / n).round() * n; // cyclic wrap, e.g. peak 0 vs interp n−0.2
            d
        };
        let r = (dev(up, peak_up) - dev(down, peak_down)) / 2.0;
        self.drift_f += 0.3 * (r - self.drift_f);
        let samples = (self.drift_f * self.sym_len as f64 / n).round() as i64;
        if samples >= 2 {
            // Windows are late: start the next block earlier by re-using
            // tail samples of the current one.
            self.rewind = samples as usize;
            self.drift_f = 0.0;
        } else if samples <= -2 {
            // Windows are early: drop a few samples.
            self.pending_skip += (-samples) as usize;
            self.drift_f = 0.0;
        }
    }

    /// Joint score vector for one header symbol from soft-combined up/down
    /// score vectors (same metric `detect_dual_shifted` maximises).
    fn joint_header_scores(&self, su: &[f32], sd: &[f32]) -> Vec<f32> {
        let n = self.n;
        let shift = self.down_shift.rem_euclid(n as i64) as usize;
        (0..n.min(su.len()))
            .map(|c| su[c] + sd[((n - c) % n + shift) % n])
            .collect()
    }

    /// The joint constraint of a clean dual block:
    /// (up + down − shift) mod N ∈ {0, ±1}.
    fn joint_ok(&self, peak_up: usize, peak_down: usize) -> bool {
        let n = self.n as i64;
        let r = (peak_up as i64 + peak_down as i64 - self.down_shift).rem_euclid(n);
        r <= 1 || r >= n - 1
    }

    /// Joint-constraint accounting for header/data blocks. Returns whether
    /// the block looked consistent (used as an RS erasure hint when it
    /// didn't).
    fn tally_joint(&mut self, peak_up: usize, peak_down: usize) -> bool {
        let valid = self.joint_ok(peak_up, peak_down);
        self.tally_stream(valid);
        valid
    }

    /// Symbol-reliability accounting shared by both modes (turbo tallies the
    /// up and down streams separately).
    fn tally_stream(&mut self, ok: bool) {
        if ok {
            self.status.symbols_rx += 1;
            self.status.erasure_count = (self.status.erasure_count - 1).max(0);
        } else {
            self.status.erasure_count += 1;
        }
    }

    pub fn status(&self) -> &DemodStatus {
        &self.status
    }

    /// The front-end whitener's learned noise floor (None until primed).
    pub fn noise_floor(&self) -> Option<&[f32]> {
        self.whitener.floor_snapshot()
    }

    /// Take ownership of any frames completed since the last call.
    pub fn take_frames(&mut self) -> Vec<RxFrame> {
        std::mem::take(&mut self.frames)
    }

    /// Decode a sample-aligned frame (e.g. software loopback, where symbol
    /// boundaries are known exactly). Skips the L1 preamble, sync word and L2
    /// preamble, reads the length header to learn the payload byte count, then
    /// jointly dechirps each dual block to recover the symbols.
    ///
    /// Returns `(payload_symbols, rate_code, turbo, byte_count)`, or `None`
    /// if the buffer is too short for the framing. (Loopback/test aid; live
    /// RX uses `feed`.)
    #[allow(dead_code)]
    pub fn decode_aligned(&self, audio: &[f32]) -> Option<(Vec<u32>, u8, bool, u16)> {
        let m = &self.cfg.modem;
        let sf = m.spreading_factor;
        let sym = self.sym_len;
        if sym == 0 {
            return None;
        }

        let scores = |pos: usize| -> (Vec<f32>, Vec<f32>) {
            let block = &audio[pos..pos + sym];
            (
                self.dech.fold_scores(block, &self.conj_up, self.n),
                self.dech.fold_scores(block, &self.conj_down, self.n),
            )
        };
        let detect = |pos: usize| -> u32 {
            let (up, down) = scores(pos);
            detect_dual(&up, &down, self.n) as u32
        };
        // Turbo block: two independent symbols. Sample-aligned audio has no
        // CFO, so the down symbol is recovered with shift 0: t = (N−pd) mod N.
        let detect_turbo = |pos: usize| -> (u32, u32) {
            let (up, down) = scores(pos);
            let (pu, _) = peak_and_quality(&up);
            let (pd, _) = peak_and_quality(&down);
            (pu as u32, ((self.n - pd) % self.n) as u32)
        };

        // Offset past the preamble/sync section. Each L1 and sync symbol is one
        // chirp (length `sym`); each L2 symbol is one summed block (also `sym`).
        let preamble_syms = m.l1_preamble_symbols + m.sync_symbols + m.l2_preamble_symbols;
        let mut pos = preamble_syms as usize * sym;

        // Read the frame header (HEADER_REPEATS copies, soft-combined).
        let hdr_count = header_symbol_count(sf);
        if pos + hdr_count * HEADER_REPEATS * sym > audio.len() {
            return None;
        }
        let mut hdr_syms = Vec::with_capacity(hdr_count);
        for i in 0..hdr_count {
            let (mut su, mut sd) = scores(pos + i * sym);
            for r in 1..HEADER_REPEATS {
                let (u2, d2) = scores(pos + (i + r * hdr_count) * sym);
                for (a, b) in su.iter_mut().zip(u2.iter()) {
                    *a += *b;
                }
                for (a, b) in sd.iter_mut().zip(d2.iter()) {
                    *a += *b;
                }
            }
            hdr_syms.push(detect_dual(&su, &sd, self.n) as u32);
        }
        pos += hdr_count * HEADER_REPEATS * sym;
        let (rate_code, turbo, byte_count) = decode_header(&hdr_syms, sf)?;

        // Number of data symbols that `byte_count` bytes pack into at sf bits
        // (the modulator zero-pads the final symbol's low bits).
        let data_sym_count = (byte_count as usize * 8).div_ceil(sf as usize);

        let mut syms = Vec::with_capacity(data_sym_count);
        while syms.len() < data_sym_count {
            if pos + sym > audio.len() {
                break;
            }
            if turbo {
                let (su, sd) = detect_turbo(pos);
                syms.push(su);
                if syms.len() < data_sym_count {
                    syms.push(sd);
                }
            } else {
                syms.push(detect(pos));
            }
            pos += sym;
        }
        Some((syms, rate_code, turbo, byte_count))
    }
}

/// Cyclic distance between two bins is ≤ 1 (peak rounding tolerance).
fn bins_close(a: usize, b: usize, n: usize) -> bool {
    let d = (a as i64 - b as i64).rem_euclid(n as i64);
    d <= 1 || d >= n as i64 - 1
}

/// Fractional peak position by 3-point parabolic interpolation (cyclic).
/// One detection bin spans L/N ≈ 18 samples at sf=10, so an integer-bin
/// timing estimate can be off by ±half a bin (~9 samples) — enough to make
/// every later data symbol round ambiguously. Interpolation brings the
/// timing residual down to a couple of samples.
fn interp_peak(scores: &[f32], idx: usize, n: usize) -> f64 {
    let ym1 = scores[(idx + n - 1) % n] as f64;
    let y0 = scores[idx] as f64;
    let yp1 = scores[(idx + 1) % n] as f64;
    let denom = ym1 - 2.0 * y0 + yp1;
    let frac = if denom.abs() < 1e-12 {
        0.0
    } else {
        (0.5 * (ym1 - yp1) / denom).clamp(-0.5, 0.5)
    };
    idx as f64 + frac
}

/// Mean of all values except the `top_k` largest — matches numpy
/// `np.mean(np.partition(spec, -8)[:-8])`.
fn mean_excluding_top(spec: &[f32], top_k: usize) -> f32 {
    if spec.len() <= top_k {
        return 0.0;
    }
    let mut sorted = spec.to_vec();
    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
    let keep = &sorted[..sorted.len() - top_k];
    keep.iter().sum::<f32>() / keep.len() as f32
}

// ── RS codec ─────────────────────────────────────────────────────────────────

/// Reed-Solomon codec over GF(256). Wraps the `reed-solomon` crate, which caps a
/// block at 255 bytes, so longer messages are chunked exactly as Python's
/// `reedsolo.RSCodec` does (data chunks of 255-nsym, each followed by nsym ECC).
pub struct RsCodec {
    nsym: usize,
}

impl RsCodec {
    pub fn new(cfg: &AppConfig) -> Self {
        // Map code rate → number of error-correction symbols (n - k).
        let nsym = match cfg.fec.rs_code_rate.as_str() {
            "1/2" => 16,
            "1/3" => 32,
            "2/3" => 8,
            _ => 16,
        };
        Self { nsym }
    }

    pub fn encode(&self, data: &[u8]) -> Vec<u8> {
        if self.nsym == 0 || data.is_empty() {
            return data.to_vec();
        }
        let enc = reed_solomon::Encoder::new(self.nsym);
        let chunk_len = 255 - self.nsym;
        let mut out = Vec::new();
        for chunk in data.chunks(chunk_len) {
            let encoded = enc.encode(chunk); // data + ecc
            out.extend_from_slice(&encoded[..]);
        }
        out
    }

    /// Plain decode at this codec's configured rate. (Test aid; the live path
    /// uses `rs_decode` with the rate carried in each frame's header.)
    #[allow(dead_code)]
    pub fn decode(&self, data: &[u8]) -> Vec<u8> {
        rs_decode(data, self.nsym, &[]).0
    }

    /// Pack CSS symbols (sf bits each) into bytes, MSB-first, zero-padding the
    /// final partial byte.
    pub fn symbols_to_bytes(&self, symbols: &[u32], sf: u32) -> Vec<u8> {
        let sf = sf as u64;
        let mut out = Vec::with_capacity((symbols.len() * sf as usize).div_ceil(8));
        let mut acc: u64 = 0;
        let mut nbits: u64 = 0;
        for &s in symbols {
            acc = (acc << sf) | s as u64;
            nbits += sf;
            while nbits >= 8 {
                out.push((acc >> (nbits - 8)) as u8);
                nbits -= 8;
                acc &= (1 << nbits) - 1;
            }
        }
        if nbits > 0 {
            out.push((acc << (8 - nbits)) as u8);
        }
        out
    }

    /// Unpack bytes into CSS symbols (sf bits each, MSB-first). The bit stream
    /// is zero-padded up to a whole symbol so no trailing bits are lost; the
    /// receiver truncates back to the byte count carried in the frame header.
    pub fn bytes_to_symbols(&self, data: &[u8], sf: u32) -> Vec<u32> {
        let sf = sf as u64;
        let mut out = Vec::new();
        if sf == 0 || data.is_empty() {
            return out;
        }
        let mut acc: u64 = 0;
        let mut nbits: u64 = 0;
        for &b in data {
            acc = (acc << 8) | b as u64;
            nbits += 8;
            while nbits >= sf {
                out.push((acc >> (nbits - sf)) as u32);
                nbits -= sf;
                acc &= (1 << nbits) - 1;
            }
        }
        if nbits > 0 {
            out.push((acc << (sf - nbits)) as u32);
        }
        out
    }
}

/// Erasure-aware Reed-Solomon decode over 255-byte chunks.
///
/// `erasures` holds byte positions (in the whole encoded stream) the demod
/// flagged as unreliable — RS corrects up to nsym erasures vs nsym/2 unknown
/// errors, so good hints double the correction power on weak links. If the
/// erasure-aided attempt fails the chunk is retried without hints (the flags
/// themselves can be wrong). Returns (decoded, corrected_byte_count);
/// uncorrectable input yields an empty Vec (matching Python's `b""`).
fn rs_decode(data: &[u8], nsym: usize, erasures: &[usize]) -> (Vec<u8>, usize) {
    if nsym == 0 || data.is_empty() {
        return (data.to_vec(), 0);
    }
    let dec = reed_solomon::Decoder::new(nsym);
    let mut out = Vec::new();
    let mut errors = 0usize;
    for (ci, chunk) in data.chunks(255).enumerate() {
        // A valid RS block carries at least one data byte on top of its nsym
        // ecc bytes. Anything shorter (garbled header byte_count) would make
        // the decoder crate underflow `len - nsym` and panic.
        if chunk.len() <= nsym {
            return (Vec::new(), errors);
        }
        let base = ci * 255;
        let epos: Vec<u8> = erasures
            .iter()
            .filter(|&&p| p >= base && p < base + chunk.len())
            .map(|&p| (p - base) as u8)
            .take(nsym)
            .collect();
        let first = if epos.is_empty() {
            dec.correct_err_count(chunk, None)
        } else {
            dec.correct_err_count(chunk, Some(&epos))
                .or_else(|_| dec.correct_err_count(chunk, None))
        };
        match first {
            Ok((recovered, n)) => {
                out.extend_from_slice(recovered.data());
                errors += n;
            }
            Err(_) => return (Vec::new(), errors),
        }
    }
    (out, errors)
}

// ── Auto-detect scanner ───────────────────────────────────────────────────────

/// Longest symbol duration (seconds) a scan candidate may have. Filters out
/// absurd SF/BW combinations (e.g. SF-12 at 500 Hz ≈ 8 s/symbol) that would
/// take half a minute just to acquire.
const SCAN_MAX_SYMBOL_SECS: f64 = 4.0;

/// One (SF, BW) hypothesis of the auto-detect scanner: a minimal L1-preamble
/// detector (up-reference only) with its own block segmentation.
struct ScanCandidate {
    sf: u32,
    bw: u32,
    conj_up: Vec<Complex<f32>>,
    dech: Dechirper,
    buf: Vec<f32>,
    sym_len: usize,
    n: usize,
    prev_bin: Option<usize>,
}

impl ScanCandidate {
    fn new(base: &AppConfig, sf: u32, bw: u32) -> Self {
        let mut cfg = base.clone();
        cfg.modem.spreading_factor = sf;
        cfg.modem.set_band(base.modem.center_freq(), bw);
        let fs = cfg.audio.sample_rate;
        let fl = cfg.modem.freq_low;
        let conj_up = make_conj_reference(sf, cfg.modem.bandwidth(), fs, fl, 1);
        let sym_len = conj_up.len();
        Self {
            sf,
            bw: cfg.modem.bandwidth(),
            conj_up,
            dech: Dechirper::new(sym_len),
            buf: Vec::new(),
            sym_len,
            n: 1usize << sf,
            prev_bin: None,
        }
    }

    /// Feed samples; returns `Some(pending_skip)` when two consecutive blocks
    /// agree on a preamble tone (same acquisition logic as the demodulator).
    fn feed(&mut self, samples: &[f32]) -> Option<usize> {
        self.buf.extend_from_slice(samples);
        while self.buf.len() >= self.sym_len {
            let block: Vec<f32> = self.buf.drain(..self.sym_len).collect();
            let scores = self.dech.fold_scores(&block, &self.conj_up, self.n);
            let (p, q) = peak_and_quality(&scores);
            if q >= DETECT_QUALITY {
                if let Some(prev) = self.prev_bin {
                    if bins_close(prev, p, self.n) {
                        let p_frac = interp_peak(&scores, p, self.n);
                        let delta = ((p_frac * self.sym_len as f64 / self.n as f64).round() as i64)
                            .rem_euclid(self.sym_len as i64)
                            as usize;
                        self.prev_bin = None;
                        return Some(if delta == 0 { 0 } else { self.sym_len - delta });
                    }
                }
                self.prev_bin = Some(p);
            } else {
                self.prev_bin = None;
            }
        }
        None
    }

    fn reset(&mut self) {
        self.buf.clear();
        self.prev_bin = None;
    }
}

/// Auto-detecting receiver: runs an L1 detector for every SF × BW-preset
/// hypothesis (at the configured centre frequency) in parallel; when one
/// locks, a full demodulator is latched onto that hypothesis and handed the
/// candidate's buffer + timing estimate. The coding rate comes from the frame
/// header, so it needs no scanning. After the frame completes (or the lock is
/// lost) scanning resumes.
pub struct RxScanner {
    candidates: Vec<ScanCandidate>,
    /// One band-pass per distinct bandwidth preset (hypotheses with the same
    /// BW share band edges, so they share a filtered stream).
    filters: Vec<(u32, BandPass)>,
    /// Shared spectral whitener — band-agnostic, so one instance feeds every
    /// band-pass and its state follows the latched demodulator.
    whitener: Whitener,
    active: Option<CssDemodulator>,
    idle_streak: u32,
    last_status: DemodStatus,
    pending_frames: Vec<RxFrame>,
    base_cfg: AppConfig,
}

impl RxScanner {
    pub fn new(base_cfg: AppConfig) -> Self {
        let mut candidates = Vec::new();
        let mut filters: Vec<(u32, BandPass)> = Vec::new();
        for sf in 7..=12u32 {
            for &bw in crate::config::BW_PRESETS.iter() {
                if (1u64 << sf) as f64 / bw as f64 <= SCAN_MAX_SYMBOL_SECS {
                    let cand = ScanCandidate::new(&base_cfg, sf, bw);
                    if !filters.iter().any(|(b, _)| *b == cand.bw) {
                        let mut cfg = base_cfg.clone();
                        cfg.modem.set_band(base_cfg.modem.center_freq(), bw);
                        let m = &cfg.modem;
                        let g = band_guard(m.bandwidth());
                        filters.push((
                            cand.bw,
                            BandPass::new(
                                cfg.audio.sample_rate,
                                m.freq_low as f64 - g,
                                (m.freq_low + m.bandwidth()) as f64 + g,
                            ),
                        ));
                    }
                    candidates.push(cand);
                }
            }
        }
        Self {
            candidates,
            filters,
            whitener: Whitener::new(),
            active: None,
            idle_streak: 0,
            last_status: DemodStatus::default(),
            pending_frames: Vec::new(),
            base_cfg,
        }
    }

    pub fn feed(&mut self, samples: &[f32]) -> Vec<DemodStatus> {
        if let Some(active) = &mut self.active {
            let updates = active.feed(samples);
            for u in &updates {
                if u.state == FrameState::Idle && !u.delta_locked {
                    self.idle_streak += 1;
                } else {
                    self.idle_streak = 0;
                }
            }
            if let Some(last) = updates.last() {
                self.last_status = last.clone();
            }
            // Frame over (decoded or lost) — release and resume scanning.
            if self.idle_streak >= 3 {
                let mut demod = self.active.take().unwrap();
                self.pending_frames.extend(demod.take_frames());
                self.idle_streak = 0;
                for c in &mut self.candidates {
                    c.reset();
                }
            }
            return updates;
        }

        // Scanning: whiten once, band-limit once per BW preset, feed that
        // preset's hypotheses the filtered stream, latch on the first lock.
        let white = self.whitener.process(samples);
        let mut latch: Option<(usize, usize)> = None;
        'scan: for (bw, filt) in &mut self.filters {
            let filtered = filt.process(&white);
            for (i, cand) in self.candidates.iter_mut().enumerate() {
                if cand.bw != *bw {
                    continue;
                }
                if let Some(skip) = cand.feed(&filtered) {
                    latch = Some((i, skip));
                    break 'scan;
                }
            }
        }
        if let Some((i, skip)) = latch {
            let cand_buf = std::mem::take(&mut self.candidates[i].buf);
            let (sf, bw) = (self.candidates[i].sf, self.candidates[i].bw);
            let mut cfg = self.base_cfg.clone();
            cfg.modem.spreading_factor = sf;
            cfg.modem.set_band(self.base_cfg.modem.center_freq(), bw);
            let mut demod = CssDemodulator::new(cfg);
            // The candidate's buffer is already whitened + band-limited; hand
            // over the front-end state so the stream continues seamlessly.
            if let Some((_, f)) = self.filters.iter().find(|(b, _)| *b == bw) {
                demod.adopt_front_end(self.whitener.clone(), f.clone());
            }
            demod.seed_acquisition(&cand_buf, skip);
            let updates = demod.feed(&[]); // chew through the adopted buffer
            if let Some(last) = updates.last() {
                self.last_status = last.clone();
            }
            self.active = Some(demod);
            for c in &mut self.candidates {
                c.reset();
            }
            return updates;
        }
        Vec::new()
    }

    pub fn take_frames(&mut self) -> Vec<RxFrame> {
        let mut out = std::mem::take(&mut self.pending_frames);
        if let Some(active) = &mut self.active {
            out.extend(active.take_frames());
        }
        out
    }

    pub fn status(&self) -> DemodStatus {
        self.last_status.clone()
    }

    /// Noise floor from the freshest whitener: the latched demodulator's if a
    /// frame is being decoded, else the shared scanning instance's.
    pub fn noise_floor(&self) -> Option<&[f32]> {
        match &self.active {
            Some(d) => d.noise_floor(),
            None => self.whitener.floor_snapshot(),
        }
    }
}

// ── High-level transmit helper ────────────────────────────────────────────────

/// Live receive path: a fixed-parameter demodulator, or the auto-detect
/// scanner, selected by `modem.auto_rx`.
enum RxPath {
    Fixed(Box<CssDemodulator>),
    Auto(Box<RxScanner>),
}

impl RxPath {
    fn feed(&mut self, samples: &[f32]) -> Vec<DemodStatus> {
        match self {
            RxPath::Fixed(d) => d.feed(samples),
            RxPath::Auto(s) => s.feed(samples),
        }
    }

    fn take_frames(&mut self) -> Vec<RxFrame> {
        match self {
            RxPath::Fixed(d) => d.take_frames(),
            RxPath::Auto(s) => s.take_frames(),
        }
    }

    fn status(&self) -> DemodStatus {
        match self {
            RxPath::Fixed(d) => d.status().clone(),
            RxPath::Auto(s) => s.status(),
        }
    }

    fn noise_floor(&self) -> Option<&[f32]> {
        match self {
            RxPath::Fixed(d) => d.noise_floor(),
            RxPath::Auto(s) => s.noise_floor(),
        }
    }
}

/// A decoded message with the parameters it arrived on.
#[derive(Debug, Clone)]
pub struct DecodedMsg {
    pub text: String,
    pub sf: u32,
    pub bw: u32,
    pub rate_code: u8,
    pub turbo: bool,
    pub snr_db: f32,
}

/// Top-level TX/RX coordinator.
/// text → RS → symbols → modulate → audio (TX)
/// audio → acquire → symbols → RS → text  (RX)
pub struct PacketEngine {
    cfg: AppConfig,
    modulator: CssModulator,
    rx: RxPath,
    rs: RsCodec,
    rs_error_total: i32,
}

impl PacketEngine {
    pub fn new(cfg: AppConfig) -> Self {
        Self {
            modulator: CssModulator::new(cfg.clone()),
            rx: Self::make_rx_path(&cfg),
            rs: RsCodec::new(&cfg),
            rs_error_total: 0,
            cfg,
        }
    }

    fn make_rx_path(cfg: &AppConfig) -> RxPath {
        if cfg.modem.auto_rx {
            RxPath::Auto(Box::new(RxScanner::new(cfg.clone())))
        } else {
            RxPath::Fixed(Box::new(CssDemodulator::new(cfg.clone())))
        }
    }

    /// Encode text into a dual-CSS audio buffer ready for the DAC.
    pub fn encode_text(&self, text: &str) -> Vec<f32> {
        let sf = self.cfg.modem.spreading_factor;
        let data = text.as_bytes();
        let enc = self.rs.encode(data);
        let syms = self.rs.bytes_to_symbols(&enc, sf);
        // The header carries the coding rate, the turbo flag and the
        // RS-encoded byte count so the receiver knows how to decode and
        // where the packet ends.
        let rc = rate_code(&self.cfg.fec.rs_code_rate);
        self.modulator
            .modulate(&syms, rc, enc.len() as u16, self.cfg.modem.turbo)
    }

    /// Feed received audio into the demodulator; returns per-symbol status updates.
    pub fn feed_rx(&mut self, samples: &[f32]) -> Vec<DemodStatus> {
        self.rx.feed(samples)
    }

    /// RS-decode and return any frames the live receiver has completed since
    /// the last call (with the SF/BW/rate they arrived on). Frames that fail
    /// RS decode are dropped; corrected byte errors are tallied for the
    /// status panel.
    pub fn take_decoded_texts(&mut self) -> Vec<DecodedMsg> {
        let frames = self.rx.take_frames();
        let mut out = Vec::new();
        for frame in frames {
            let (result, errors) = decode_frame(&self.rs, &frame);
            self.rs_error_total += errors as i32;
            if let Some(text) = result {
                out.push(DecodedMsg {
                    text,
                    sf: frame.sf,
                    bw: frame.bw,
                    rate_code: frame.rate_code,
                    turbo: frame.turbo,
                    snr_db: frame.snr_db,
                });
            }
        }
        out
    }

    /// Full sample-aligned decode: audio → symbols → text. (Loopback/test aid;
    /// live RX uses `feed_rx` + `take_decoded_texts`.)
    #[allow(dead_code)]
    pub fn decode_audio(&self, audio: &[f32]) -> Option<String> {
        let demod = CssDemodulator::new(self.cfg.clone());
        let (syms, rc, turbo, bc) = demod.decode_aligned(audio)?;
        let frame = RxFrame {
            symbols: syms,
            bad_symbols: Vec::new(),
            byte_count: bc,
            rate_code: rc,
            turbo,
            sf: self.cfg.modem.spreading_factor,
            bw: self.cfg.modem.bandwidth(),
            snr_db: 0.0,
        };
        decode_frame(&self.rs, &frame).0
    }

    /// Live status snapshot (RS corrected-error total folded in).
    pub fn status(&self) -> DemodStatus {
        let mut s = self.rx.status();
        s.error_count = self.rs_error_total;
        s
    }

    /// Where the current bandwidth would sit quietest, judged by the
    /// whitener's learned noise floor. `None` until RX audio has primed the
    /// floor estimate.
    pub fn noise_report(&self) -> Option<NoiseReport> {
        let floor = self.rx.noise_floor()?;
        let m = &self.cfg.modem;
        let fs = self.cfg.audio.sample_rate;
        let bw = m.bandwidth();
        let (best_center, best_mean) = quietest_band(floor, fs, bw)?;
        let cur_mean = band_floor_mean(
            floor,
            fs,
            m.freq_low as f64,
            (m.freq_low + bw) as f64,
        );
        // Floor stores magnitudes, so power ratio in dB is 20·log10.
        let advantage_db = 20.0 * (cur_mean.max(1e-12) / best_mean.max(1e-12)).log10();
        Some(NoiseReport {
            quietest_center: best_center,
            current_center: m.center_freq(),
            advantage_db,
        })
    }

    /// Rebuild internal state after a config change.
    pub fn rebuild(&mut self, cfg: AppConfig) {
        self.modulator = CssModulator::new(cfg.clone());
        self.rx = Self::make_rx_path(&cfg);
        self.rs = RsCodec::new(&cfg);
        self.cfg = cfg;
    }

    /// Live TX output-level change: only the modulator cares, so the RX path
    /// (scanner hypotheses, whitener floor, any frame in progress) survives.
    pub fn set_output_level(&mut self, level: f32) {
        self.cfg.audio.output_level = level.clamp(0.0, 1.0);
        self.modulator = CssModulator::new(self.cfg.clone());
    }
}

/// Noise-floor placement advice from the whitener's learned spectrum.
#[derive(Debug, Clone, Copy)]
pub struct NoiseReport {
    /// Quietest centre frequency for the current bandwidth, Hz.
    pub quietest_center: u32,
    /// Where the band currently sits, Hz.
    pub current_center: u32,
    /// How much quieter the best slot is than the current one, dB
    /// (≈ 0 when the band is already well placed).
    pub advantage_db: f32,
}

/// Heuristic SF/rate/turbo advice from a decoded frame's dechirp-domain SNR.
///
/// The deltas are rules of thumb, not calibration: each SF step is ~3 dB of
/// coherent-integration gain, turbo costs ~3 dB (no joint combining and
/// quality-only erasures), and each RS rate step is worth ~1.5 dB. The
/// target leaves a couple of dB of fading headroom over the ~8 dB lock
/// threshold. Only decoded frames have a measured SNR, so the advice always
/// starts from a working link.
pub fn advise_from_snr(snr_db: f32, sf: u32, rate_code: u8, turbo: bool) -> String {
    const TARGET_DB: f32 = 10.0;
    const TURBO_DB: f32 = 3.0;
    const SF_STEP_DB: f32 = 3.0;
    const RATE_STEP_DB: f32 = 1.5;
    // Robust→fast rate ladder by code index: 1/3 → 1/2 → 2/3.
    const LADDER: [u8; 3] = [1, 0, 2];
    let rung = LADDER.iter().position(|&r| r == rate_code).unwrap_or(1);

    let margin = snr_db - TARGET_DB;
    let mut steps: Vec<String> = Vec::new();

    if margin >= 0.0 {
        // Spend the spare margin on speed, cheapest win first.
        let mut spare = margin;
        if !turbo && spare >= TURBO_DB {
            steps.push("turbo on".into());
            spare -= TURBO_DB;
        }
        let mut s = sf;
        while s > 7 && spare >= SF_STEP_DB {
            s -= 1;
            spare -= SF_STEP_DB;
        }
        if s != sf {
            steps.push(format!("SF-{s}"));
        }
        let mut r = rung;
        while r + 1 < LADDER.len() && spare >= RATE_STEP_DB {
            r += 1;
            spare -= RATE_STEP_DB;
        }
        if r != rung {
            steps.push(format!("rate {}", rate_label(LADDER[r])));
        }
        if steps.is_empty() {
            format!("settings match conditions ({margin:+.0} dB spare)")
        } else {
            format!("{margin:+.0} dB spare — try {}", steps.join(", "))
        }
    } else {
        // Buy back the shortfall with robustness, cheapest first.
        let mut need = -margin;
        if turbo {
            steps.push("turbo off".into());
            need -= TURBO_DB;
        }
        let mut r = rung;
        while r > 0 && need > 0.0 {
            r -= 1;
            need -= RATE_STEP_DB;
        }
        if r != rung {
            steps.push(format!("rate {}", rate_label(LADDER[r])));
        }
        let mut s = sf;
        while s < 12 && need > 0.0 {
            s += 1;
            need -= SF_STEP_DB;
        }
        if s != sf {
            steps.push(format!("SF-{s}"));
        }
        if steps.is_empty() {
            format!("marginal copy ({margin:+.0} dB) at the most robust settings")
        } else {
            format!("{margin:+.0} dB short — try {}", steps.join(", "))
        }
    }
}

/// Symbols → bytes → erasure-aware RS decode → UTF-8, using the rate carried
/// in the frame header. Returns (text, corrected_byte_count).
fn decode_frame(rs: &RsCodec, frame: &RxFrame) -> (Option<String>, usize) {
    let sf = frame.sf as usize;
    let mut bytes = rs.symbols_to_bytes(&frame.symbols, frame.sf);
    let want = frame.byte_count as usize;
    if bytes.len() < want {
        return (None, 0);
    }
    bytes.truncate(want);

    // Map joint-constraint failures to byte erasure positions.
    let mut erasures: Vec<usize> = Vec::new();
    for (i, bad) in frame.bad_symbols.iter().enumerate() {
        if *bad {
            let lo = i * sf / 8;
            let hi = ((i + 1) * sf - 1) / 8;
            for b in lo..=hi.min(want.saturating_sub(1)) {
                erasures.push(b);
            }
        }
    }
    erasures.dedup();

    let (decoded, errors) = rs_decode(&bytes, nsym_for_rate(frame.rate_code), &erasures);
    if decoded.is_empty() {
        return (None, errors);
    }
    (String::from_utf8(decoded).ok(), errors)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn chirp_length_matches_formula() {
        // N=1024, bw=2700, fs=48000 → round(1024/2700*48000) = 18204 samples
        let c = make_chirp(10, 2700, 48000, 300, 0, 1);
        assert_eq!(c.len(), 18204);
        // All samples within [-1, 1]
        assert!(c.iter().all(|&x| (-1.0..=1.0).contains(&x)));
    }

    #[test]
    fn modulate_respects_output_level() {
        let cfg = AppConfig::default();
        let level = cfg.audio.output_level;
        let m = CssModulator::new(cfg);
        let audio = m.modulate(&[1, 2, 3], 0, 6, false);
        let peak = audio.iter().fold(0.0f32, |a, &x| a.max(x.abs()));
        // Peak should be ≈ level * 0.9 after normalisation.
        assert!((peak - level * 0.9).abs() < 1e-3, "peak was {peak}");
    }

    #[test]
    fn rs_roundtrip_recovers_data() {
        let cfg = AppConfig::default(); // rate 1/2 → nsym 16
        let rs = RsCodec::new(&cfg);
        let msg = b"CQ CQ DE TEST";
        let enc = rs.encode(msg);
        assert_eq!(enc.len(), msg.len() + 16);
        let dec = rs.decode(&enc);
        assert_eq!(&dec, msg);
    }

    #[test]
    fn rs_corrects_errors() {
        let cfg = AppConfig::default();
        let rs = RsCodec::new(&cfg);
        let msg = b"HELLO WORLD";
        let mut enc = rs.encode(msg);
        // Corrupt up to nsym/2 = 8 bytes — should still recover.
        enc[0] ^= 0xFF;
        enc[3] ^= 0xFF;
        enc[5] ^= 0xFF;
        let dec = rs.decode(&enc);
        assert_eq!(&dec, msg);
    }

    #[test]
    fn rs_decode_rejects_undersized_input_without_panicking() {
        // Regression: a garbled header once produced byte_count=3 with nsym=16;
        // the reed-solomon crate underflows `len - nsym` on such input and
        // panics (range end 2^64-13). rs_decode must fail cleanly instead.
        let (out, _) = rs_decode(&[1, 2, 3], 16, &[]);
        assert!(out.is_empty());
        // Same for a short trailing chunk after a full 255-byte one.
        let mut data = vec![0u8; 255];
        data.extend_from_slice(&[1, 2, 3]);
        let (out, _) = rs_decode(&data, 16, &[]);
        assert!(out.is_empty());
        // Exactly nsym bytes (zero data bytes) is also not a valid block.
        let (out, _) = rs_decode(&[0u8; 16], 16, &[]);
        assert!(out.is_empty());
    }

    #[test]
    fn symbol_byte_packing_roundtrip() {
        let cfg = AppConfig::default();
        let rs = RsCodec::new(&cfg);
        let data = b"\x12\x34\x56\x78";
        let syms = rs.bytes_to_symbols(data, 8);
        // sf=8 → one symbol per byte
        assert_eq!(syms, vec![0x12, 0x34, 0x56, 0x78]);
        let back = rs.symbols_to_bytes(&syms, 8);
        assert_eq!(&back, data);
    }

    #[test]
    fn detect_recovers_modulated_symbol() {
        // A single up-chirp for symbol S should peak at bin S when dechirped
        // against the conjugate symbol-0 up reference.
        let (sf, bw, fs, fl) = (8u32, 2700u32, 48000u32, 300u32);
        let n = 1usize << sf;
        let sym = 42u32;
        let chirp = make_chirp(sf, bw, fs, fl, sym, 1);
        let reference = make_conj_reference(sf, bw, fs, fl, 1);
        let detected = detect_symbol(&chirp, &reference, n);
        let diff = (detected as i64 - sym as i64).abs();
        assert!(diff <= 1, "detected {detected}, expected ~{sym}");
    }

    #[test]
    fn dual_block_detection_sweep() {
        // Regression for the sf=8 loopback failure: a summed up+down dual
        // block must detect as its up symbol at every sf, including symbols
        // whose old image tone landed bin-centred.
        let (bw, fs, fl) = (2700u32, 48000u32, 300u32);
        for sf in 7..=12u32 {
            let n = 1u32 << sf;
            let dech = Dechirper::new(make_chirp(sf, bw, fs, fl, 0, 1).len());
            let conj_up = make_conj_reference(sf, bw, fs, fl, 1);
            let conj_down = make_conj_reference(sf, bw, fs, fl, -1);
            for &s in &[0u32, 1, 57, n / 3, n / 2, n - 2, n - 1] {
                let s = s % n;
                let s_inv = (n - s) % n;
                let up = make_chirp(sf, bw, fs, fl, s, 1);
                let down = make_chirp(sf, bw, fs, fl, s_inv, -1);
                let block = sum_chirps(&up, &down);
                let su = dech.fold_scores(&block, &conj_up, n as usize);
                let sd = dech.fold_scores(&block, &conj_down, n as usize);
                let got = detect_dual(&su, &sd, n as usize) as u32;
                assert_eq!(got, s, "sf={sf} symbol={s}");
            }
        }
    }

    #[test]
    fn header_codec_roundtrip() {
        for sf in 7..=12u32 {
            for rc in 0..=2u8 {
                for turbo in [false, true] {
                    for &val in &[1u16, 42, 255, 2048, 0x1FFF] {
                        let syms = encode_header(rc, val, turbo, sf);
                        assert_eq!(syms.len(), header_symbol_count(sf));
                        assert_eq!(
                            decode_header(&syms, sf),
                            Some((rc, turbo, val)),
                            "sf={sf} rc={rc} turbo={turbo} val={val}"
                        );
                    }
                }
            }
        }
    }

    #[test]
    fn header_crc_rejects_corruption() {
        // A corrupted header must come back None, not a garbage byte count —
        // a believed-garbage count left the decoder stuck in DATA for minutes.
        for sf in 7..=12u32 {
            let good = encode_header(1, 300, true, sf);
            assert_eq!(decode_header(&good, sf), Some((1, true, 300)));
            for (i, _) in good.iter().enumerate() {
                // Flip high bits of each symbol — low bits of the last symbol
                // can be padding outside the 24-bit header, which decode
                // rightly ignores.
                for flip in [1u32 << (sf - 1), 0b11 << (sf - 2)] {
                    let mut bad = good.clone();
                    bad[i] ^= flip;
                    assert_eq!(
                        decode_header(&bad, sf),
                        None,
                        "sf={sf} sym={i} flip={flip:#b} accepted a corrupted header"
                    );
                }
            }
        }
    }

    #[test]
    fn header_soft_decode_recovers_runner_up() {
        // One noisy header symbol whose true bin came second must not cost
        // the frame: the candidate search finds the CRC-valid combination.
        let sf = 8u32;
        let n = 256usize;
        let syms = encode_header(2, 200, false, sf);
        let mut per_sym: Vec<Vec<f32>> = syms
            .iter()
            .map(|&s| {
                let mut v = vec![0.1f32; n];
                v[s as usize] = 10.0;
                v
            })
            .collect();

        // An impostor bin outscores the true bin in symbol 1 — a hard slice
        // would fail the CRC and drop the frame.
        let wrong = (syms[1] as usize + 7) % n;
        per_sym[1][wrong] = 11.0;
        assert_eq!(decode_header_soft(&per_sym, sf), Some((2, false, 200)));

        // Two ambiguous symbols still recover.
        let wrong0 = (syms[0] as usize + 3) % n;
        per_sym[0][wrong0] = 10.5;
        assert_eq!(decode_header_soft(&per_sym, sf), Some((2, false, 200)));

        // When the true bin drops out of the candidate set entirely, the CRC
        // rejects every combination instead of inventing a header.
        per_sym[1][syms[1] as usize] = 0.1;
        per_sym[1][(syms[1] as usize + 50) % n] = 10.9;
        per_sym[1][(syms[1] as usize + 90) % n] = 10.8;
        assert_eq!(decode_header_soft(&per_sym, sf), None);
    }

    /// Build a config with a given spreading factor / code rate for tests.
    fn cfg_with(sf: u32, rate: &str) -> AppConfig {
        let mut cfg = AppConfig::default();
        cfg.modem.spreading_factor = sf;
        cfg.fec.rs_code_rate = rate.to_string();
        cfg
    }

    #[test]
    fn loopback_roundtrip_decodes_text() {
        // The core Phase-1 proof: encode → (no channel) → decode === input.
        let msg = "CQ CQ DE TEST DE TEST K";
        for sf in 7..=12u32 {
            for rate in ["1/2", "1/3", "2/3"] {
                let pkt = PacketEngine::new(cfg_with(sf, rate));
                let audio = pkt.encode_text(msg);
                let decoded = pkt.decode_audio(&audio);
                assert_eq!(
                    decoded.as_deref(),
                    Some(msg),
                    "loopback failed at sf={sf} rate={rate}"
                );
            }
        }
    }

    #[test]
    fn loopback_roundtrip_short_and_varied() {
        let cases = ["A", "Hello, world!", "73", "5NN TU"];
        let pkt = PacketEngine::new(AppConfig::default());
        for msg in cases {
            let audio = pkt.encode_text(msg);
            assert_eq!(
                pkt.decode_audio(&audio).as_deref(),
                Some(msg),
                "msg={msg:?}"
            );
        }
    }

    #[test]
    fn loopback_survives_mild_noise() {
        // Add deterministic low-level noise; RS(1/2) should still recover.
        let msg = "NOISE TEST 123";
        let pkt = PacketEngine::new(AppConfig::default());
        let mut audio = pkt.encode_text(msg);

        // Deterministic pseudo-noise (no rng dependency): a hashed sample index.
        let peak = audio.iter().fold(0.0f32, |a, &x| a.max(x.abs())).max(1e-6);
        let mut seed: u32 = 0x1234_5678;
        for x in audio.iter_mut() {
            seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
            let r = (seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5; // [-0.5,0.5)
            *x += r * 0.08 * peak; // ~8% peak noise
        }
        assert_eq!(pkt.decode_audio(&audio).as_deref(), Some(msg));
    }

    /// Deterministic noise generator shared by the streaming tests.
    fn add_noise(audio: &mut [f32], level: f32, mut seed: u32) {
        let peak = audio.iter().fold(0.0f32, |a, &x| a.max(x.abs())).max(1e-6);
        for x in audio.iter_mut() {
            seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
            let r = (seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5;
            *x += r * level * peak;
        }
    }

    #[test]
    fn streaming_unaligned_decode() {
        // The live-RX proof: the state machine must acquire timing from the
        // preamble and decode a frame that arrives at an arbitrary sample
        // offset, in small chunks, with noise padding either side. Run both
        // the fixed-parameter path and the auto-detect scanner.
        let msg = "CQ CQ DE N0CALL K";
        for auto in [false, true] {
            for (offset, noise, chunk) in [(3777usize, 0.02f32, 512usize), (1234, 0.06, 960)] {
                let mut cfg = AppConfig::default();
                cfg.modem.auto_rx = auto;
                let mut pkt = PacketEngine::new(cfg);
                let frame = pkt.encode_text(msg);

                let mut stream = vec![0.0f32; offset];
                stream.extend_from_slice(&frame);
                stream.extend(vec![0.0f32; 12000]);
                add_noise(&mut stream, noise, 0xDEAD_BEEF ^ offset as u32);

                let mut decoded = Vec::new();
                for chunk_samples in stream.chunks(chunk) {
                    pkt.feed_rx(chunk_samples);
                    decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
                }
                assert_eq!(
                    decoded,
                    vec![msg.to_string()],
                    "auto={auto} offset={offset} noise={noise} chunk={chunk}"
                );
            }
        }
    }

    #[test]
    fn turbo_block_detection_sweep() {
        // Turbo blocks carry two independent symbols; per-stream detection
        // must recover both exactly despite each chirp's leakage into the
        // other stream's dechirp spectrum (no joint constraint to clean up
        // after a near-miss).
        let (bw, fs, fl) = (2700u32, 48000u32, 300u32);
        for sf in 7..=12u32 {
            let n = 1u32 << sf;
            let dech = Dechirper::new(make_chirp(sf, bw, fs, fl, 0, 1).len());
            let conj_up = make_conj_reference(sf, bw, fs, fl, 1);
            let conj_down = make_conj_reference(sf, bw, fs, fl, -1);
            for &(s, t) in &[
                (0u32, 1u32),
                (1, n - 1),
                (57, 0),
                (n / 3, n / 2),
                (n / 2, n / 3),
                (n - 1, 57),
                (n - 2, n - 2),
            ] {
                let (s, t) = (s % n, t % n);
                let up = make_chirp(sf, bw, fs, fl, s, 1);
                let down = make_chirp(sf, bw, fs, fl, (n - t) % n, -1);
                let block = sum_chirps(&up, &down);
                let su = dech.fold_scores(&block, &conj_up, n as usize);
                let sd = dech.fold_scores(&block, &conj_down, n as usize);
                let (pu, _) = peak_and_quality(&su);
                let (pd, _) = peak_and_quality(&sd);
                let got_t = ((n as usize - pd) % n as usize) as u32;
                assert_eq!(pu as u32, s, "sf={sf} up stream (s={s} t={t})");
                assert_eq!(got_t, t, "sf={sf} down stream (s={s} t={t})");
            }
        }
    }

    #[test]
    fn turbo_loopback_roundtrip_decodes_text() {
        // Turbo aligned decode must recover the text at every SF and rate —
        // the sweep covers both parities of the data symbol count, so the
        // odd-count down-stream pad is exercised too.
        let msg = "TURBO MODE TEST DE N0CALL";
        for sf in 7..=12u32 {
            for rate in ["1/2", "1/3", "2/3"] {
                let mut cfg = cfg_with(sf, rate);
                cfg.modem.turbo = true;
                let pkt = PacketEngine::new(cfg);
                let audio = pkt.encode_text(msg);
                assert_eq!(
                    pkt.decode_audio(&audio).as_deref(),
                    Some(msg),
                    "turbo loopback failed at sf={sf} rate={rate}"
                );
            }
        }
    }

    #[test]
    fn turbo_halves_data_airtime() {
        // Same message, same overhead (preambles + header); the data section
        // must shrink to exactly ceil(syms/2) blocks.
        let msg = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG 0123456789";
        let mut cfg = AppConfig::default();
        let robust = PacketEngine::new(cfg.clone()).encode_text(msg);
        cfg.modem.turbo = true;
        let turbo = PacketEngine::new(cfg.clone()).encode_text(msg);

        let m = &cfg.modem;
        let sym = make_chirp(
            m.spreading_factor,
            m.bandwidth(),
            cfg.audio.sample_rate,
            m.freq_low,
            0,
            1,
        )
        .len();
        let overhead = (m.l1_preamble_symbols + m.sync_symbols + m.l2_preamble_symbols) as usize
            + header_symbol_count(m.spreading_factor) * HEADER_REPEATS;
        let data_robust = robust.len() / sym - overhead;
        let data_turbo = turbo.len() / sym - overhead;
        assert_eq!(data_turbo, data_robust.div_ceil(2));
    }

    #[test]
    fn turbo_streaming_unaligned_decode() {
        // Live-path turbo proof: arbitrary sample offset, chunked feed,
        // noise. The receiver is configured with turbo OFF — the mode must
        // come from the frame header alone — on both the fixed path and the
        // auto-detect scanner.
        let msg = "TURBO LIVE TEST DE N0CALL K";
        for auto in [false, true] {
            let mut tx_cfg = AppConfig::default();
            tx_cfg.modem.turbo = true;
            let frame = PacketEngine::new(tx_cfg).encode_text(msg);

            let mut rx_cfg = AppConfig::default();
            rx_cfg.modem.auto_rx = auto;
            let mut pkt = PacketEngine::new(rx_cfg);

            let mut stream = vec![0.0f32; 3777];
            stream.extend_from_slice(&frame);
            stream.extend(vec![0.0f32; 12000]);
            add_noise(&mut stream, 0.02, 0x7E57_0BB0);

            let mut decoded = Vec::new();
            for chunk in stream.chunks(512) {
                pkt.feed_rx(chunk);
                decoded.extend(pkt.take_decoded_texts());
            }
            assert_eq!(decoded.len(), 1, "auto={auto}");
            assert_eq!(decoded[0].text, msg, "auto={auto}");
            assert!(decoded[0].turbo, "auto={auto} turbo flag not reported");
        }
    }

    #[test]
    fn streaming_back_to_back_frames() {
        // Two frames separated by a short gap must both decode and the state
        // machine must re-arm in between.
        let msgs = ["FIRST FRAME", "SECOND ONE 73"];
        let mut cfg = AppConfig::default();
        cfg.modem.auto_rx = false;
        let mut pkt = PacketEngine::new(cfg);

        let mut stream = vec![0.0f32; 2000];
        for m in msgs {
            stream.extend_from_slice(&pkt.encode_text(m));
            stream.extend(vec![0.0f32; 30000]);
        }
        add_noise(&mut stream, 0.02, 0x1357_9BDF);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(1024) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(
            decoded,
            msgs.iter().map(|m| m.to_string()).collect::<Vec<_>>()
        );
    }

    #[test]
    fn auto_detects_sf_bw_and_rate() {
        // TX at SF-8 / 1500 Hz / rate 1/3; the receiver runs with completely
        // different defaults (SF-10 / 2700 / 1/2) and auto_rx on. It must
        // find the right hypothesis and report the detected parameters.
        let msg = "AUTO DETECT TEST 73";
        let mut tx_cfg = AppConfig::default();
        tx_cfg.modem.spreading_factor = 8;
        tx_cfg.modem.set_band(1650, 1500);
        tx_cfg.fec.rs_code_rate = "1/3".into();
        let tx = PacketEngine::new(tx_cfg);
        let frame = tx.encode_text(msg);

        let mut rx = PacketEngine::new(AppConfig::default()); // auto_rx = true
        let mut stream = vec![0.0f32; 5000];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 20000]);
        add_noise(&mut stream, 0.03, 0xA5A5_5A5A);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(1024) {
            rx.feed_rx(chunk);
            decoded.extend(rx.take_decoded_texts());
        }
        assert_eq!(decoded.len(), 1, "expected exactly one decode");
        let m = &decoded[0];
        assert_eq!(m.text, msg);
        assert_eq!(m.sf, 8, "wrong SF detected");
        assert_eq!(m.bw, 1500, "wrong bandwidth detected");
        assert_eq!(rate_label(m.rate_code), "1/3", "wrong rate from header");
    }

    #[test]
    fn erasure_hints_rescue_heavy_symbol_loss() {
        // Wipe out 7 well-separated data symbols (~14 byte erasures at sf=10).
        // Errors-only RS(…,16) corrects at most 8 unknown errors, so this
        // frame is only recoverable because the joint-constraint failures are
        // fed to the decoder as erasures (capacity 16).
        let msg = "ERASURE STRESS TEST!"; // 20 bytes → 36 RS bytes → 29 syms
        let mut cfg = AppConfig::default();
        cfg.modem.auto_rx = false;
        let mut pkt = PacketEngine::new(cfg.clone());
        let frame = pkt.encode_text(msg);

        let sym = make_chirp(10, 2700, 48000, 300, 0, 1).len();
        let lead = 4000usize;
        let mut stream = vec![0.0f32; lead];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 12000]);

        // Data blocks start after 16 preamble blocks + 4 header blocks.
        let m = &cfg.modem;
        let data_start = (m.l1_preamble_symbols + m.sync_symbols + m.l2_preamble_symbols) as usize
            + header_symbol_count(m.spreading_factor) * HEADER_REPEATS;
        for k in [0usize, 4, 8, 12, 16, 20, 24] {
            let pos = lead + (data_start + k) * sym;
            for s in &mut stream[pos..pos + sym] {
                *s = 0.0;
            }
        }
        add_noise(&mut stream, 0.02, 0x0BAD_F00D);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(decoded, vec![msg.to_string()]);
    }

    #[test]
    fn truncated_transmission_aborts_instead_of_decoding_forever() {
        // TX dies mid-frame (or the header's length outlived the signal): the
        // decoder must bail out of DATA after a hopeless run of bad symbols,
        // not sit there slicing noise until the full count arrives.
        let mut cfg = cfg_with(8, "1/2");
        cfg.modem.auto_rx = false;
        let pkt = PacketEngine::new(cfg.clone());
        let msg = "ABCDEFGH".repeat(15); // 120 bytes → 136 RS bytes → 136 syms
        let frame = pkt.encode_text(&msg);

        let m = &cfg.modem;
        let sym = make_chirp(8, m.bandwidth(), 48000, m.freq_low, 0, 1).len();
        let data_start = (m.l1_preamble_symbols + m.sync_symbols + m.l2_preamble_symbols) as usize
            + header_symbol_count(8) * HEADER_REPEATS;

        // Keep preamble + header + 30 of the 136 data symbols, then noise for
        // 60 symbol times — long past the abort limit (36 at sf=8 rate 1/2)
        // but well short of the 106 symbols a stuck decoder would wait for.
        let mut stream = frame[..(data_start + 30) * sym].to_vec();
        let mut tail = vec![0.0f32; 60 * sym];
        add_noise(&mut tail, 0.5, 0xDEAD_BEEF);
        stream.extend_from_slice(&tail);

        let mut demod = CssDemodulator::new(cfg);
        for chunk in stream.chunks(2048) {
            demod.feed(chunk);
        }
        assert!(demod.take_frames().is_empty(), "noise frame got through");
        assert_ne!(
            demod.status().state,
            FrameState::Data,
            "decoder is still stuck in DATA after the signal died"
        );
    }

    #[test]
    fn bandpass_passes_band_and_rejects_outside() {
        // Band 1150–2150 Hz (+40 Hz guard): a mid-band tone must come through
        // at ~unity gain, tones well outside must be crushed.
        let rms = |freq: f64| -> f32 {
            let mut f = BandPass::new(48000, 1110.0, 2190.0);
            let tone: Vec<f32> = (0..48000)
                .map(|i| (2.0 * PI * freq * i as f64 / 48000.0).sin() as f32)
                .collect();
            let y = f.process(&tone);
            let tail = &y[24000..]; // past the filter's settling transient
            (tail.iter().map(|v| v * v).sum::<f32>() / tail.len() as f32).sqrt()
        };
        let unity = (0.5f32).sqrt(); // RMS of a unit sine
        let inband = rms(1650.0);
        assert!(
            (inband / unity).abs() > 0.9 && (inband / unity) < 1.1,
            "in-band gain {inband}"
        );
        for f in [100.0, 700.0, 2700.0, 5000.0] {
            let out = rms(f);
            assert!(
                out / unity < 0.01,
                "tone at {f} Hz only attenuated to {} of unity",
                out / unity
            );
        }
    }

    #[test]
    fn loud_out_of_band_tone_does_not_break_decode() {
        // A steady tone outside the band still sweeps through the dechirp
        // bins (tone − reference crosses 0..BW during the symbol), so without
        // the band-pass front end it floods the detector — at 10× the
        // signal's peak amplitude the receiver can't even acquire the
        // preamble. 550 Hz above the top band edge.
        let msg = "TONE INTERFERENCE TEST";
        let mut cfg = cfg_with(8, "1/2");
        cfg.modem.auto_rx = false;
        cfg.modem.set_band(1650, 1000); // band 1150–2150 Hz
        let mut pkt = PacketEngine::new(cfg.clone());
        let frame = pkt.encode_text(msg);
        let peak = frame.iter().fold(0.0f32, |a, &x| a.max(x.abs()));

        let mut stream = vec![0.0f32; 3000];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 12000]);
        for (i, s) in stream.iter_mut().enumerate() {
            *s += 10.0 * peak * (2.0 * PI * 2700.0 * i as f64 / 48000.0).sin() as f32;
        }
        add_noise(&mut stream, 0.02, 0x70E0_2900);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(decoded, vec![msg.to_string()]);
    }

    /// Deterministic uniform noise in [-0.5, 0.5) (absolute amplitude, unlike
    /// `add_noise` which scales to the buffer's peak).
    fn raw_noise(len: usize, mut seed: u32) -> Vec<f32> {
        (0..len)
            .map(|_| {
                seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
                (seed >> 8) as f32 / (1u32 << 24) as f32 - 0.5
            })
            .collect()
    }

    /// Amplitude of the `freq` Hz component of `x` (fs = 48 kHz).
    fn tone_amp(x: &[f32], freq: f64) -> f32 {
        let (mut c, mut s) = (0.0f64, 0.0f64);
        for (i, &v) in x.iter().enumerate() {
            let ph = 2.0 * PI * freq * i as f64 / 48000.0;
            c += v as f64 * ph.cos();
            s += v as f64 * ph.sin();
        }
        (2.0 * (c * c + s * s).sqrt() / x.len() as f64) as f32
    }

    #[test]
    fn whitener_is_transparent_to_white_noise() {
        // White noise already has a flat floor — grading it on the curve must
        // change (almost) nothing: WOLA reconstruction at ~unity gain.
        let noise = raw_noise(96000, 0x1357_9BDF);
        let mut w = Whitener::new();
        let out = w.process(&noise);
        let rms = |x: &[f32]| (x.iter().map(|v| v * v).sum::<f32>() / x.len() as f32).sqrt();
        let (rin, rout) = (rms(&noise[4096..]), rms(&out[4096..]));
        assert!(
            (rout / rin) > 0.8 && (rout / rin) < 1.2,
            "white noise should pass at ~unity, got gain {}",
            rout / rin
        );
    }

    #[test]
    fn whitener_notches_persistent_carrier() {
        // A steady tone 20+ dB above the noise must be learned into the floor
        // and graded down to the noise's level; the broadband noise itself
        // must survive.
        let mut audio = raw_noise(480_000, 0x0DD5_EED5); // 10 s, RMS ≈ 0.029
        for v in audio.iter_mut() {
            *v *= 0.1;
        }
        for (i, v) in audio.iter_mut().enumerate() {
            *v += 0.5 * (2.0 * PI * 1000.0 * i as f64 / 48000.0).sin() as f32;
        }
        let mut w = Whitener::new();
        let out = w.process(&audio);
        let last = &out[out.len() - 48000..];
        let carrier = tone_amp(last, 1000.0);
        assert!(
            carrier < 0.05,
            "carrier only reduced to {carrier} (input 0.5)"
        );
        let rms = (last.iter().map(|v| v * v).sum::<f32>() / last.len() as f32).sqrt();
        assert!(rms > 0.015, "broadband noise was crushed too (rms {rms})");
    }

    #[test]
    fn whitener_freeze_stops_floor_learning() {
        // With learning frozen (as the demod does while a frame is in
        // progress) a carrier appearing afterwards must NOT be learned into
        // the floor — at the narrow presets the "carrier" would be the
        // signal's own chirp dwelling in a bin.
        let mut w = Whitener::new();
        let mut prime = raw_noise(96000, 0xFEED_F00D); // 2 s of plain noise
        for v in prime.iter_mut() {
            *v *= 0.1;
        }
        w.process(&prime);
        w.set_learning(false);

        let mut audio = raw_noise(240_000, 0xCAFE_D00D); // 5 s
        for v in audio.iter_mut() {
            *v *= 0.1;
        }
        for (i, v) in audio.iter_mut().enumerate() {
            *v += 0.5 * (2.0 * PI * 1000.0 * i as f64 / 48000.0).sin() as f32;
        }
        let out = w.process(&audio);
        let last = &out[out.len() - 48000..];
        let carrier = tone_amp(last, 1000.0);
        assert!(
            carrier > 0.25,
            "frozen whitener still notched the carrier to {carrier} (input 0.5)"
        );
    }

    #[test]
    fn quietest_band_finds_the_notch() {
        // Synthetic floor: flat at 1.0 with a quiet dip over 2000–2500 Hz —
        // the search must park a 500 Hz band inside the dip.
        let fs = 48000u32;
        let bin_hz = fs as f64 / WHITEN_FFT as f64;
        let floor: Vec<f32> = (0..WHITEN_BINS)
            .map(|k| {
                let f = k as f64 * bin_hz;
                if (2000.0..2500.0).contains(&f) {
                    0.1
                } else {
                    1.0
                }
            })
            .collect();
        let (center, mean) = quietest_band(&floor, fs, 500).unwrap();
        assert!(
            (2150..=2350).contains(&center),
            "center {center} not inside the dip"
        );
        assert!(mean < 0.3, "best mean {mean}");
        // A bandwidth filling the whole passband has exactly one placement.
        let (c, _) = quietest_band(&floor, fs, 2700).unwrap();
        assert_eq!(c, 1650);
    }

    #[test]
    fn advise_from_snr_directions() {
        // Lots of margin at slow settings → speed up, turbo first.
        let a = advise_from_snr(25.0, 12, 1, false);
        assert!(a.contains("turbo on") && a.contains("SF-"), "{a}");
        // Comfortable but nothing affordable → settings confirmed.
        let b = advise_from_snr(11.0, 7, 2, true);
        assert!(b.contains("match"), "{b}");
        // Short of target on the fastest settings → back off turbo first.
        let c = advise_from_snr(2.0, 7, 2, true);
        assert!(c.contains("turbo off"), "{c}");
        // Short of target with nothing left to give → say so.
        let d = advise_from_snr(5.0, 12, 1, false);
        assert!(d.contains("robust"), "{d}");
    }

    #[test]
    fn narrowband_loopback_decodes_text() {
        // The 100/250 Hz presets, at every SF the scanner would hypothesise
        // for them (symbol time ≤ 4 s).
        let msg = "NARROW 73";
        for (bw, sfs) in [(100u32, 7..=8u32), (250, 7..=9)] {
            for sf in sfs {
                let mut cfg = cfg_with(sf, "1/2");
                cfg.modem.set_band(1500, bw);
                let pkt = PacketEngine::new(cfg);
                let audio = pkt.encode_text(msg);
                assert_eq!(
                    pkt.decode_audio(&audio).as_deref(),
                    Some(msg),
                    "bw={bw} sf={sf}"
                );
            }
        }
    }

    #[test]
    fn narrowband_streaming_decode_250hz() {
        // Live path at a narrow preset: timing acquisition, the whitener
        // (with mid-frame learning freeze) and the band-pass all at 250 Hz.
        let msg = "QRP TEST";
        let mut cfg = cfg_with(7, "2/3");
        cfg.modem.auto_rx = false;
        cfg.modem.set_band(1500, 250);
        let mut pkt = PacketEngine::new(cfg);
        let frame = pkt.encode_text(msg);

        let mut stream = vec![0.0f32; 5000];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 30000]);
        add_noise(&mut stream, 0.03, 0x0BAD_5EED);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(decoded, vec![msg.to_string()]);
    }

    #[test]
    fn scanner_hypothesises_narrow_presets() {
        // 100/250 Hz join the scan inside the 4 s symbol cap: SF 7–8 at
        // 100 Hz, SF 7–9 at 250 Hz, nothing slower.
        let sc = RxScanner::new(AppConfig::default());
        let has = |sf, bw| sc.candidates.iter().any(|c| c.sf == sf && c.bw == bw);
        assert!(has(7, 100) && has(8, 100) && !has(9, 100));
        assert!(has(9, 250) && !has(10, 250));
    }

    #[test]
    fn loud_in_band_carrier_does_not_break_decode() {
        // A steady tone *inside* the band is the band-pass filter's blind
        // spot and floods the dechirp bins (the tone sweeps through all of
        // them after dechirping). The whitener learns it as part of the noise
        // floor and grades it down before detection. Without the whitener
        // this carrier (6× the signal peak, mid-band) kills the decode.
        let msg = "IN BAND CARRIER TEST";
        let mut cfg = cfg_with(8, "1/2");
        cfg.modem.auto_rx = false;
        cfg.modem.set_band(1650, 1000); // band 1150–2150 Hz
        let mut pkt = PacketEngine::new(cfg.clone());
        let frame = pkt.encode_text(msg);
        let peak = frame.iter().fold(0.0f32, |a, &x| a.max(x.abs()));

        // Carrier-only lead-in so the floor estimate converges, as it would
        // tuning across a band with a birdie already present.
        let mut stream = vec![0.0f32; 96_000];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 12000]);
        for (i, s) in stream.iter_mut().enumerate() {
            *s += 6.0 * peak * (2.0 * PI * 1800.0 * i as f64 / 48000.0).sin() as f32;
        }
        add_noise(&mut stream, 0.02, 0x1B4D_CA22);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(decoded, vec![msg.to_string()]);
    }

    #[test]
    fn faded_sync_chirp_does_not_shift_the_frame() {
        // The first sync downchirp is wiped out, so sync detection can only
        // fire on the second one. Alignment must come from spotting the L2
        // up tone's return, not from counting blocks after detection —
        // counting put the header window one symbol late, the CRC rejected
        // the garbage, and the frame was lost ("loses sync after header").
        let msg = "SYNC FADE TEST DE N0CALL 73";
        let mut cfg = cfg_with(8, "1/2");
        cfg.modem.auto_rx = false;
        let mut pkt = PacketEngine::new(cfg.clone());
        let frame = pkt.encode_text(msg);

        let m = &cfg.modem;
        let sym = make_chirp(8, m.bandwidth(), 48000, m.freq_low, 0, 1).len();
        let lead = 2000usize;
        let mut stream = vec![0.0f32; lead];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 12000]);

        let first_sync = lead + m.l1_preamble_symbols as usize * sym;
        for s in &mut stream[first_sync..first_sync + sym] {
            *s = 0.0;
        }
        add_noise(&mut stream, 0.02, 0x5EED_50FA);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(decoded, vec![msg.to_string()]);
    }

    #[test]
    fn acoustic_reverb_does_not_break_acquisition() {
        // Speaker→mic in a room: echo taps keep upchirp energy ringing into
        // the sync downchirps. Sync detection must not require the up stream
        // to have gone quiet, and the frame must still decode.
        let msg = "REVERB ROOM TEST 599";
        let mut cfg = cfg_with(8, "1/2");
        cfg.modem.auto_rx = false;
        let mut pkt = PacketEngine::new(cfg.clone());
        let frame = pkt.encode_text(msg);

        let mut stream = vec![0.0f32; 3000];
        stream.extend_from_slice(&frame);
        stream.extend(vec![0.0f32; 12000]);

        // Three echo taps (13/29/47 ms at 48 kHz), early-reflection levels.
        let dry = stream.clone();
        for (delay_ms, gain) in [(13usize, 0.5f32), (29, 0.35), (47, 0.25)] {
            let d = delay_ms * 48;
            for i in d..stream.len() {
                stream[i] += gain * dry[i - d];
            }
        }
        add_noise(&mut stream, 0.02, 0xEC0E_C0E5);

        let mut decoded = Vec::new();
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
            decoded.extend(pkt.take_decoded_texts().into_iter().map(|m| m.text));
        }
        assert_eq!(decoded, vec![msg.to_string()]);
    }

    /// Linear-interpolation resampler: simulates RX/TX sample clocks that
    /// disagree by some ppm (two different soundcards).
    fn resample(audio: &[f32], ratio: f64) -> Vec<f32> {
        let out_len = (audio.len() as f64 / ratio) as usize;
        (0..out_len)
            .map(|i| {
                let p = i as f64 * ratio;
                let k = p as usize;
                let f = (p - k as f64) as f32;
                if k + 1 < audio.len() {
                    audio[k] * (1.0 - f) + audio[k + 1] * f
                } else {
                    audio[audio.len() - 1]
                }
            })
            .collect()
    }

    #[test]
    fn long_frame_survives_clock_drift() {
        // ±100 ppm clock offset walks the dechirp tones ~2.7 bins across this
        // frame — without drift tracking the symbols silently mis-slice (the
        // joint constraint can't see timing drift, so RS gets no erasure
        // hints either) and the decode fails.
        let msg = "THE QUICK BROWN FOX JUMPS OVER THE LAZY DOG 0123456789 #".repeat(3);
        for ppm in [0.0f64, 100.0, -100.0] {
            let mut cfg = cfg_with(7, "1/2");
            cfg.modem.auto_rx = false;
            let pkt = PacketEngine::new(cfg.clone());
            let audio = pkt.encode_text(&msg);
            let warped = resample(&audio, 1.0 + ppm * 1e-6);

            let mut rx = PacketEngine::new(cfg);
            let mut decoded = Vec::new();
            // Trailing silence: a fast RX clock shortens the resampled stream
            // so the final symbol would otherwise sit incomplete in the
            // buffer; live RX keeps capturing past the end of a transmission.
            let mut stream = warped;
            stream.extend(vec![0.0f32; 8192]);
            for chunk in stream.chunks(4096) {
                rx.feed_rx(chunk);
                decoded.extend(rx.take_decoded_texts().into_iter().map(|d| d.text));
            }
            assert_eq!(decoded, vec![msg.clone()], "ppm={ppm}");
        }
    }

    #[test]
    fn idle_noise_produces_no_frames() {
        // Pure noise must never get past the header sanity check.
        let mut pkt = PacketEngine::new(AppConfig::default());
        let mut stream = vec![0.0f32; 48000 * 2];
        add_noise(&mut stream, 1.0, 0xCAFE_F00D);
        for chunk in stream.chunks(2048) {
            pkt.feed_rx(chunk);
        }
        assert!(pkt.take_decoded_texts().is_empty());
    }

    #[test]
    fn symbol_packing_pads_instead_of_dropping_bits() {
        // 39 bytes at sf=10 is 312 bits → 32 symbols (8 pad bits); the old
        // floor() packing emitted 31 symbols and silently lost 2 data bits.
        let cfg = AppConfig::default();
        let rs = RsCodec::new(&cfg);
        let data: Vec<u8> = (0..39u8).map(|i| i.wrapping_mul(7) ^ 0x5A).collect();
        let syms = rs.bytes_to_symbols(&data, 10);
        assert_eq!(syms.len(), 32);
        let mut back = rs.symbols_to_bytes(&syms, 10);
        back.truncate(data.len());
        assert_eq!(back, data);
    }
}
