// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// SPDX-License-Identifier: GPL-2.0-or-later

//! Audio capture, playback, and TCP streaming for trx-server.

#[cfg(feature = "ft2")]
use std::collections::HashMap;
use std::collections::{HashSet, VecDeque};
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use base64::Engine as _;
use bytes::Bytes;
use flate2::write::GzEncoder;
use flate2::Compression;
use num_complex::Complex;
use std::io::Write as _;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, mpsc, watch};
use tracing::{error, info, info_span, warn};

use trx_ais::AisDecoder;
use trx_aprs::AprsDecoder;
use trx_core::audio::{
    parse_vchan_uuid_msg, read_audio_msg, write_audio_msg, write_vchan_audio_frame,
    write_vchan_uuid_msg, AudioStreamInfo, AUDIO_MSG_AIS_DECODE, AUDIO_MSG_APRS_DECODE,
    AUDIO_MSG_CW_DECODE, AUDIO_MSG_FT2_DECODE, AUDIO_MSG_FT4_DECODE, AUDIO_MSG_FT8_DECODE,
    AUDIO_MSG_HF_APRS_DECODE, AUDIO_MSG_HISTORY_COMPRESSED, AUDIO_MSG_LRPT_IMAGE,
    AUDIO_MSG_LRPT_PROGRESS, AUDIO_MSG_RX_FRAME, AUDIO_MSG_STREAM_INFO, AUDIO_MSG_TX_FRAME,
    AUDIO_MSG_VCHAN_ALLOCATED, AUDIO_MSG_VCHAN_BW, AUDIO_MSG_VCHAN_DESTROYED, AUDIO_MSG_VCHAN_FREQ,
    AUDIO_MSG_VCHAN_MODE, AUDIO_MSG_VCHAN_REMOVE, AUDIO_MSG_VCHAN_SUB, AUDIO_MSG_VCHAN_UNSUB,
    AUDIO_MSG_VDES_DECODE, AUDIO_MSG_WEFAX_DECODE, AUDIO_MSG_WEFAX_PROGRESS, AUDIO_MSG_WSPR_DECODE,
};
use trx_core::decode::{
    AisMessage, AprsPacket, CwEvent, DecodedMessage, Ft8Message, LrptImage, LrptProgress,
    VdesMessage, WefaxMessage, WsprMessage,
};
use trx_core::rig::state::{RigMode, RigState};
use trx_core::vchan::SharedVChanManager;
use trx_cw::CwDecoder;
use trx_ftx::Ft8Decoder;
use trx_vdes::VdesDecoder;
use trx_wspr::WsprDecoder;
use uuid::Uuid;

use crate::config::AudioConfig;
use trx_decode_log::DecoderLoggers;

const APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const HF_APRS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const AIS_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const VDES_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const CW_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const FT8_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WSPR_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const LRPT_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
const WEFAX_HISTORY_RETENTION: Duration = Duration::from_secs(24 * 60 * 60);
/// Maximum entries per decoder history queue.  Prevents unbounded memory growth
/// on busy channels (e.g. AIS near a port).  Oldest entries are evicted when
/// the limit is reached, independent of the time-based pruning.
const MAX_HISTORY_ENTRIES: usize = 10_000;
/// Silence timeout before auto-finalising an LRPT pass (30 s without new MCUs).
const LRPT_PASS_SILENCE_TIMEOUT: Duration = Duration::from_secs(30);
const FT8_SAMPLE_RATE: u32 = 12_000;
#[cfg(feature = "ft2")]
const FT2_ASYNC_BUFFER_SAMPLES: usize = 45_000;
#[cfg(feature = "ft2")]
const FT2_ASYNC_TRIGGER_SAMPLES: usize = 9_000;
#[cfg(feature = "ft2")]
const FT2_DEDUPE_RETENTION: Duration = Duration::from_secs(8);
const DECODE_AUDIO_GATE_RMS: f32 = 2.5e-4;
const AUDIO_STREAM_ERROR_LOG_INTERVAL: Duration = Duration::from_secs(60);
const AUDIO_STREAM_RECOVERY_DELAY: Duration = Duration::from_secs(1);

fn current_timestamp_ms() -> i64 {
    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
        Ok(dur) => dur.as_millis() as i64,
        Err(_) => 0,
    }
}

#[cfg(feature = "ft2")]
fn retain_ft2_window(buf: &mut Vec<f32>) {
    if buf.len() > FT2_ASYNC_BUFFER_SAMPLES {
        let excess = buf.len() - FT2_ASYNC_BUFFER_SAMPLES;
        buf.drain(..excess);
    }
}

#[cfg(feature = "ft2")]
fn prune_recent_ft2_decodes(recent: &mut HashMap<String, Instant>, now: Instant) {
    recent.retain(|_, seen_at| now.duration_since(*seen_at) <= FT2_DEDUPE_RETENTION);
}

#[cfg(feature = "ft2")]
fn should_emit_ft2_decode(recent: &mut HashMap<String, Instant>, text: &str, freq_hz: f32) -> bool {
    let now = Instant::now();
    prune_recent_ft2_decodes(recent, now);
    let key = format!("{}|{}", text, freq_hz.round() as i32);
    if recent.contains_key(&key) {
        return false;
    }
    recent.insert(key, now);
    true
}

#[cfg(feature = "ft2")]
fn decode_ft2_window(
    decoder: &mut Ft8Decoder,
    samples: &[f32],
    max_results: usize,
) -> Vec<trx_ftx::Ft8DecodeResult> {
    let window_samples = decoder.window_samples();
    if samples.len() < window_samples {
        return Vec::new();
    }

    decoder.reset();
    let block_size = decoder.block_size();
    let start = samples.len() - window_samples;
    let window = &samples[start..];
    for block in window.chunks_exact(block_size) {
        decoder.process_block(block);
    }
    let remainder = window.chunks_exact(block_size).remainder();
    if !remainder.is_empty() {
        let mut tail = vec![0.0f32; block_size];
        tail[..remainder.len()].copy_from_slice(remainder);
        decoder.process_block(&tail);
    }
    decoder.decode_if_ready(max_results)
}

struct StreamErrorLogger {
    label: &'static str,
    state: Mutex<StreamErrorState>,
}

#[derive(Default)]
struct StreamErrorState {
    last_kind: Option<&'static str>,
    last_error: Option<String>,
    last_logged_at: Option<Instant>,
    suppressed: u64,
}

/// Outcome of a `StreamErrorLogger::log_at` call. Tests assert on this; the
/// production path emits the corresponding tracing record(s) as a side effect.
#[derive(Debug, PartialEq, Eq)]
enum LogAction {
    /// First sighting of this error class, or class transitioned. If the
    /// previous class had been suppressed, `flushed_repeated` carries the
    /// suppressed count emitted as a warn before the new error.
    Error { flushed_repeated: Option<u64> },
    /// Same class as the prior call, still inside the suppression window.
    Suppressed,
    /// Same class, but the suppression interval has elapsed — periodic
    /// summary emitted with `repeats` accumulated since the last log.
    Recurring { repeats: u64 },
}

impl StreamErrorLogger {
    fn new(label: &'static str) -> Self {
        Self {
            label,
            state: Mutex::new(StreamErrorState::default()),
        }
    }

    fn log(&self, err: &str) {
        let _ = self.log_at(err, Instant::now());
    }

    fn log_at(&self, err: &str, now: Instant) -> LogAction {
        let kind = classify_stream_error(err);
        let mut state = lock_or_recover(&self.state, self.label);

        // First occurrence or changed error class: log as error once.
        if state.last_kind != Some(kind) {
            let flushed = if state.suppressed > 0 {
                let n = state.suppressed;
                warn!(
                    "{} repeated {} times: {}",
                    self.label,
                    n,
                    state.last_error.as_deref().unwrap_or("<unknown>")
                );
                Some(n)
            } else {
                None
            };
            error!("{}: {}", self.label, err);
            state.last_kind = Some(kind);
            state.last_error = Some(err.to_string());
            state.last_logged_at = Some(now);
            state.suppressed = 0;
            return LogAction::Error {
                flushed_repeated: flushed,
            };
        }

        // Same class: suppress aggressively and emit only periodic summaries.
        state.suppressed += 1;
        let due = state
            .last_logged_at
            .map(|ts| now.duration_since(ts) >= AUDIO_STREAM_ERROR_LOG_INTERVAL)
            .unwrap_or(false);
        if due {
            let repeats = state.suppressed;
            warn!(
                "{} recurring ({} repeats/{}s): {}",
                self.label,
                repeats,
                AUDIO_STREAM_ERROR_LOG_INTERVAL.as_secs(),
                state.last_error.as_deref().unwrap_or("<unknown>")
            );
            state.last_logged_at = Some(now);
            state.suppressed = 0;
            LogAction::Recurring { repeats }
        } else {
            state.last_error = Some(err.to_string());
            LogAction::Suppressed
        }
    }
}

/// Serialise every decoder's history into a single in-memory blob of length-
/// prefixed records (`[u8 type][u32 BE len][JSON payload]`). Returns the blob
/// and the number of records written. Callers chunk + gzip + send the blob
/// over the audio socket; tests inspect it directly.
fn build_history_blob(histories: &DecoderHistories) -> (Vec<u8>, usize) {
    // Estimate ~256 bytes per message; pre-allocate to avoid repeated
    // reallocation for large histories.
    let estimated_msgs = histories.estimated_total_count().min(500_000);
    let mut blob: Vec<u8> = Vec::with_capacity(estimated_msgs.saturating_mul(256));
    let mut count = 0usize;

    macro_rules! push_history {
        ($msgs:expr, $variant:expr, $msg_type:expr) => {
            for item in $msgs {
                let wrapped = $variant(item);
                if let Ok(json) = serde_json::to_vec(&wrapped) {
                    let len = json.len() as u32;
                    blob.push($msg_type);
                    blob.extend_from_slice(&len.to_be_bytes());
                    blob.extend_from_slice(&json);
                    count += 1;
                }
            }
        };
    }

    push_history!(
        histories.snapshot_ais_history(),
        DecodedMessage::Ais,
        AUDIO_MSG_AIS_DECODE
    );
    push_history!(
        histories.snapshot_vdes_history(),
        DecodedMessage::Vdes,
        AUDIO_MSG_VDES_DECODE
    );
    push_history!(
        histories.snapshot_aprs_history(),
        DecodedMessage::Aprs,
        AUDIO_MSG_APRS_DECODE
    );
    push_history!(
        histories.snapshot_hf_aprs_history(),
        DecodedMessage::HfAprs,
        AUDIO_MSG_HF_APRS_DECODE
    );
    push_history!(
        histories.snapshot_ft8_history(),
        DecodedMessage::Ft8,
        AUDIO_MSG_FT8_DECODE
    );
    push_history!(
        histories.snapshot_ft4_history(),
        DecodedMessage::Ft4,
        AUDIO_MSG_FT4_DECODE
    );
    #[cfg(feature = "ft2")]
    push_history!(
        histories.snapshot_ft2_history(),
        DecodedMessage::Ft2,
        AUDIO_MSG_FT2_DECODE
    );
    push_history!(
        histories.snapshot_wspr_history(),
        DecodedMessage::Wspr,
        AUDIO_MSG_WSPR_DECODE
    );
    push_history!(
        histories.snapshot_cw_history(),
        DecodedMessage::Cw,
        AUDIO_MSG_CW_DECODE
    );
    push_history!(
        histories.snapshot_lrpt_history(),
        DecodedMessage::LrptImage,
        AUDIO_MSG_LRPT_IMAGE
    );
    push_history!(
        histories.snapshot_wefax_history(),
        DecodedMessage::Wefax,
        AUDIO_MSG_WEFAX_DECODE
    );

    (blob, count)
}

/// Walk a length-prefixed history blob and return the byte ranges of each
/// chunk to send. Each record is `[u8 type][u32 BE len][payload]`. A chunk is
/// flushed when adding the next record would push it past `threshold`. A
/// single record larger than `threshold` becomes its own chunk — the wire-side
/// `MAX_HISTORY_PAYLOAD_SIZE` cap is generous enough for any realistic decode.
/// A malformed tail (truncated header or payload) is dropped silently; the
/// blob is self-built so this is defence in depth, not error reporting.
fn split_history_chunks(blob: &[u8], threshold: usize) -> Vec<std::ops::Range<usize>> {
    let mut chunks = Vec::new();
    let mut chunk_start = 0usize;
    let mut pos = 0usize;
    while pos < blob.len() {
        let len_end = pos.saturating_add(5);
        if len_end > blob.len() {
            break;
        }
        let payload_len =
            u32::from_be_bytes([blob[pos + 1], blob[pos + 2], blob[pos + 3], blob[pos + 4]])
                as usize;
        let msg_end = len_end.saturating_add(payload_len);
        if msg_end > blob.len() {
            break;
        }
        if pos > chunk_start && (msg_end - chunk_start) > threshold {
            chunks.push(chunk_start..pos);
            chunk_start = pos;
        }
        pos = msg_end;
    }
    if chunk_start < pos {
        chunks.push(chunk_start..pos);
    }
    chunks
}

fn classify_stream_error(err: &str) -> &'static str {
    if err.contains("snd_pcm_poll_descriptors") || err.contains("alsa::poll() returned POLLERR") {
        "alsa_poll_failure"
    } else if err.contains("Input stream") {
        "input_stream_error"
    } else if err.contains("Output stream") {
        "output_stream_error"
    } else {
        "other_stream_error"
    }
}

/// Per-rig decoder history store.
///
/// Replaces the previous process-wide `OnceLock` statics so that each rig
/// instance can maintain its own independent history.  Pass an
/// `Arc<DecoderHistories>` into every decoder task and into the audio listener.
pub struct DecoderHistories {
    pub ais: Mutex<VecDeque<(Instant, AisMessage)>>,
    pub vdes: Mutex<VecDeque<(Instant, VdesMessage)>>,
    pub aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
    pub hf_aprs: Mutex<VecDeque<(Instant, AprsPacket)>>,
    pub cw: Mutex<VecDeque<(Instant, CwEvent)>>,
    pub ft8: Mutex<VecDeque<(Instant, Ft8Message)>>,
    pub ft4: Mutex<VecDeque<(Instant, Ft8Message)>>,
    pub ft2: Mutex<VecDeque<(Instant, Ft8Message)>>,
    pub wspr: Mutex<VecDeque<(Instant, WsprMessage)>>,
    pub lrpt: Mutex<VecDeque<(Instant, LrptImage)>>,
    pub wefax: Mutex<VecDeque<(Instant, WefaxMessage)>>,
    /// Approximate total entry count across all decoders, maintained
    /// atomically so `estimated_total_count()` avoids 11 lock acquisitions.
    total_count: AtomicUsize,
}

/// Acquire a mutex, recovering from poisoning with a warning log.
fn lock_or_recover<'a, T>(mutex: &'a Mutex<T>, label: &str) -> std::sync::MutexGuard<'a, T> {
    mutex.lock().unwrap_or_else(|e| {
        tracing::warn!(
            "Mutex for {} was poisoned (prior panic); recovering with potentially inconsistent data",
            label
        );
        e.into_inner()
    })
}

/// Enforce capacity limit on a history deque by evicting oldest entries.
fn enforce_capacity<T>(deque: &mut VecDeque<T>, max: usize) {
    while deque.len() > max {
        deque.pop_front();
    }
}

/// Drop entries older than `retention` from the front of a time-tagged deque.
/// Uses `checked_sub` so an early `now` (before `retention` elapses since the
/// monotonic clock origin) is treated as "no entries are old enough to prune"
/// rather than panicking.
fn prune_by_age<T>(deque: &mut VecDeque<(Instant, T)>, retention: Duration, now: Instant) {
    let Some(cutoff) = now.checked_sub(retention) else {
        return;
    };
    while let Some((ts, _)) = deque.front() {
        if *ts < cutoff {
            deque.pop_front();
        } else {
            break;
        }
    }
}

impl DecoderHistories {
    pub fn new() -> Arc<Self> {
        Arc::new(Self {
            ais: Mutex::new(VecDeque::new()),
            vdes: Mutex::new(VecDeque::new()),
            aprs: Mutex::new(VecDeque::new()),
            hf_aprs: Mutex::new(VecDeque::new()),
            cw: Mutex::new(VecDeque::new()),
            ft8: Mutex::new(VecDeque::new()),
            ft4: Mutex::new(VecDeque::new()),
            ft2: Mutex::new(VecDeque::new()),
            wspr: Mutex::new(VecDeque::new()),
            lrpt: Mutex::new(VecDeque::new()),
            wefax: Mutex::new(VecDeque::new()),
            total_count: AtomicUsize::new(0),
        })
    }

    /// Adjust the atomic total count after a record/prune/clear operation.
    ///
    /// Uses a CAS loop for decrements to prevent underflow wrapping the
    /// counter to `usize::MAX` (which would cause a capacity-overflow panic
    /// when pre-allocating the history replay blob).
    fn adjust_total_count(&self, old_len: usize, new_len: usize) {
        if new_len > old_len {
            self.total_count
                .fetch_add(new_len - old_len, Ordering::Relaxed);
        } else if old_len > new_len {
            let delta = old_len - new_len;
            let mut current = self.total_count.load(Ordering::Relaxed);
            loop {
                let next = current.saturating_sub(delta);
                match self.total_count.compare_exchange_weak(
                    current,
                    next,
                    Ordering::Relaxed,
                    Ordering::Relaxed,
                ) {
                    Ok(_) => break,
                    Err(actual) => current = actual,
                }
            }
        }
    }

    // --- AIS ---

    fn prune_ais(history: &mut VecDeque<(Instant, AisMessage)>) {
        prune_by_age(history, AIS_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_ais_message(&self, mut msg: AisMessage) {
        if msg.ts_ms.is_none() {
            msg.ts_ms = Some(current_timestamp_ms());
        }
        let mut h = lock_or_recover(&self.ais, "ais_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_ais(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_ais_history(&self) -> Vec<AisMessage> {
        let mut h = lock_or_recover(&self.ais, "ais_history");
        let before = h.len();
        Self::prune_ais(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter().map(|(_, msg)| msg.clone()).collect()
    }

    // --- VDES ---

    fn prune_vdes(history: &mut VecDeque<(Instant, VdesMessage)>) {
        prune_by_age(history, VDES_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_vdes_message(&self, mut msg: VdesMessage) {
        if msg.ts_ms.is_none() {
            msg.ts_ms = Some(current_timestamp_ms());
        }
        let mut h = lock_or_recover(&self.vdes, "vdes_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_vdes(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_vdes_history(&self) -> Vec<VdesMessage> {
        let mut h = lock_or_recover(&self.vdes, "vdes_history");
        let before = h.len();
        Self::prune_vdes(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter().map(|(_, msg)| msg.clone()).collect()
    }

    // --- APRS ---

    fn prune_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
        prune_by_age(history, APRS_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_aprs_packet(&self, mut pkt: AprsPacket) {
        if !pkt.crc_ok {
            return;
        }
        if pkt.ts_ms.is_none() {
            pkt.ts_ms = Some(current_timestamp_ms());
        }
        let mut h = lock_or_recover(&self.aprs, "aprs_history");
        let before = h.len();
        h.push_back((Instant::now(), pkt));
        Self::prune_aprs(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_aprs_history(&self) -> Vec<AprsPacket> {
        let mut h = lock_or_recover(&self.aprs, "aprs_history");
        let before = h.len();
        Self::prune_aprs(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
            .collect()
    }

    pub fn clear_aprs_history(&self) {
        let mut h = lock_or_recover(&self.aprs, "aprs_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- HF APRS ---

    fn prune_hf_aprs(history: &mut VecDeque<(Instant, AprsPacket)>) {
        prune_by_age(history, HF_APRS_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_hf_aprs_packet(&self, mut pkt: AprsPacket) {
        if !pkt.crc_ok {
            return;
        }
        if pkt.ts_ms.is_none() {
            pkt.ts_ms = Some(current_timestamp_ms());
        }
        let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
        let before = h.len();
        h.push_back((Instant::now(), pkt));
        Self::prune_hf_aprs(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_hf_aprs_history(&self) -> Vec<AprsPacket> {
        let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
        let before = h.len();
        Self::prune_hf_aprs(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, pkt): &(Instant, AprsPacket)| pkt.clone())
            .collect()
    }

    pub fn clear_hf_aprs_history(&self) {
        let mut h = lock_or_recover(&self.hf_aprs, "hf_aprs_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- CW ---

    fn prune_cw(history: &mut VecDeque<(Instant, CwEvent)>) {
        prune_by_age(history, CW_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_cw_event(&self, evt: CwEvent) {
        let mut h = lock_or_recover(&self.cw, "cw_history");
        let before = h.len();
        h.push_back((Instant::now(), evt));
        Self::prune_cw(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_cw_history(&self) -> Vec<CwEvent> {
        let mut h = lock_or_recover(&self.cw, "cw_history");
        let before = h.len();
        Self::prune_cw(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, evt): &(Instant, CwEvent)| evt.clone())
            .collect()
    }

    pub fn clear_cw_history(&self) {
        let mut h = lock_or_recover(&self.cw, "cw_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- FT8 ---

    fn prune_ft8(history: &mut VecDeque<(Instant, Ft8Message)>) {
        prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_ft8_message(&self, msg: Ft8Message) {
        let mut h = lock_or_recover(&self.ft8, "ft8_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_ft8(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_ft8_history(&self) -> Vec<Ft8Message> {
        let mut h = lock_or_recover(&self.ft8, "ft8_history");
        let before = h.len();
        Self::prune_ft8(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
            .collect()
    }

    pub fn clear_ft8_history(&self) {
        let mut h = lock_or_recover(&self.ft8, "ft8_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- FT4 ---

    fn prune_ft4(history: &mut VecDeque<(Instant, Ft8Message)>) {
        prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_ft4_message(&self, msg: Ft8Message) {
        let mut h = lock_or_recover(&self.ft4, "ft4_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_ft4(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_ft4_history(&self) -> Vec<Ft8Message> {
        let mut h = lock_or_recover(&self.ft4, "ft4_history");
        let before = h.len();
        Self::prune_ft4(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
            .collect()
    }

    pub fn clear_ft4_history(&self) {
        let mut h = lock_or_recover(&self.ft4, "ft4_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- FT2 ---

    #[cfg_attr(not(feature = "ft2"), allow(dead_code))]
    fn prune_ft2(history: &mut VecDeque<(Instant, Ft8Message)>) {
        prune_by_age(history, FT8_HISTORY_RETENTION, Instant::now());
    }

    #[cfg_attr(not(feature = "ft2"), allow(dead_code))]
    pub fn record_ft2_message(&self, msg: Ft8Message) {
        let mut h = lock_or_recover(&self.ft2, "ft2_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_ft2(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    #[cfg_attr(not(feature = "ft2"), allow(dead_code))]
    pub fn snapshot_ft2_history(&self) -> Vec<Ft8Message> {
        let mut h = lock_or_recover(&self.ft2, "ft2_history");
        let before = h.len();
        Self::prune_ft2(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, msg): &(Instant, Ft8Message)| msg.clone())
            .collect()
    }

    pub fn clear_ft2_history(&self) {
        let mut h = lock_or_recover(&self.ft2, "ft2_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- WSPR ---

    fn prune_wspr(history: &mut VecDeque<(Instant, WsprMessage)>) {
        prune_by_age(history, WSPR_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_wspr_message(&self, msg: WsprMessage) {
        let mut h = lock_or_recover(&self.wspr, "wspr_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_wspr(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_wspr_history(&self) -> Vec<WsprMessage> {
        let mut h = lock_or_recover(&self.wspr, "wspr_history");
        let before = h.len();
        Self::prune_wspr(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, msg): &(Instant, WsprMessage)| msg.clone())
            .collect()
    }

    pub fn clear_wspr_history(&self) {
        let mut h = lock_or_recover(&self.wspr, "wspr_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- LRPT ---

    fn prune_lrpt(history: &mut VecDeque<(Instant, LrptImage)>) {
        prune_by_age(history, LRPT_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_lrpt_image(&self, mut img: LrptImage) {
        if img.ts_ms.is_none() {
            img.ts_ms = Some(current_timestamp_ms());
        }
        let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
        let before = h.len();
        h.push_back((Instant::now(), img));
        Self::prune_lrpt(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_lrpt_history(&self) -> Vec<LrptImage> {
        let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
        let before = h.len();
        Self::prune_lrpt(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, img): &(Instant, LrptImage)| img.clone())
            .collect()
    }

    pub fn clear_lrpt_history(&self) {
        let mut h = lock_or_recover(&self.lrpt, "lrpt_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    // --- WEFAX ---

    fn prune_wefax(history: &mut VecDeque<(Instant, WefaxMessage)>) {
        prune_by_age(history, WEFAX_HISTORY_RETENTION, Instant::now());
    }

    pub fn record_wefax_message(&self, mut msg: WefaxMessage) {
        if msg.ts_ms.is_none() {
            msg.ts_ms = Some(current_timestamp_ms());
        }
        // Strip bulk PNG data before storing in memory/persistence.
        msg.png_data = None;
        let mut h = lock_or_recover(&self.wefax, "wefax_history");
        let before = h.len();
        h.push_back((Instant::now(), msg));
        Self::prune_wefax(&mut h);
        enforce_capacity(&mut h, MAX_HISTORY_ENTRIES);
        self.adjust_total_count(before, h.len());
    }

    pub fn snapshot_wefax_history(&self) -> Vec<WefaxMessage> {
        let mut h = lock_or_recover(&self.wefax, "wefax_history");
        let before = h.len();
        Self::prune_wefax(&mut h);
        self.adjust_total_count(before, h.len());
        h.iter()
            .map(|(_, msg)| {
                let mut m = msg.clone();
                // Re-read PNG from disk so remote clients can save a local copy.
                if m.png_data.is_none() {
                    if let Some(ref path) = m.path {
                        if let Ok(bytes) = std::fs::read(path) {
                            m.png_data =
                                Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
                        }
                    }
                }
                m
            })
            .collect()
    }

    pub fn clear_wefax_history(&self) {
        let mut h = lock_or_recover(&self.wefax, "wefax_history");
        let before = h.len();
        h.clear();
        self.adjust_total_count(before, 0);
    }

    /// Returns a quick (non-pruning) estimate of the total number of history
    /// entries across all decoders, used for pre-allocating the replay blob.
    ///
    /// Uses an `AtomicUsize` counter maintained by record/prune/clear methods,
    /// avoiding 9 separate mutex acquisitions.
    pub fn estimated_total_count(&self) -> usize {
        self.total_count.load(Ordering::Relaxed)
    }

    /// Rebuild the aggregate count after bulk restoration bypasses the normal
    /// record methods.
    pub(crate) fn recalculate_total_count(&self) {
        let total = lock_or_recover(&self.ais, "ais_history").len()
            + lock_or_recover(&self.vdes, "vdes_history").len()
            + lock_or_recover(&self.aprs, "aprs_history").len()
            + lock_or_recover(&self.hf_aprs, "hf_aprs_history").len()
            + lock_or_recover(&self.cw, "cw_history").len()
            + lock_or_recover(&self.ft8, "ft8_history").len()
            + lock_or_recover(&self.ft4, "ft4_history").len()
            + lock_or_recover(&self.ft2, "ft2_history").len()
            + lock_or_recover(&self.wspr, "wspr_history").len()
            + lock_or_recover(&self.lrpt, "lrpt_history").len()
            + lock_or_recover(&self.wefax, "wefax_history").len();
        self.total_count.store(total, Ordering::Relaxed);
    }
}

/// Spawn the audio capture thread.
///
/// Opens the configured input device via cpal, accumulates PCM samples into
/// frames of `frame_duration_ms` length, encodes each frame with Opus, and
/// broadcasts the resulting packets.
pub fn spawn_audio_capture(
    cfg: &AudioConfig,
    tx: broadcast::Sender<Bytes>,
    pcm_tx: Option<broadcast::Sender<Vec<f32>>>,
    shutdown_rx: watch::Receiver<bool>,
) -> std::thread::JoinHandle<()> {
    let sample_rate = cfg.sample_rate;
    let channels = cfg.channels as u16;
    let frame_duration_ms = cfg.frame_duration_ms;
    let bitrate_bps = cfg.bitrate_bps;
    let device_name = cfg.device.clone();

    std::thread::spawn(move || {
        if let Err(e) = run_capture(
            sample_rate,
            channels,
            frame_duration_ms,
            bitrate_bps,
            device_name,
            tx,
            pcm_tx,
            shutdown_rx,
        ) {
            error!("Audio capture thread error: {}", e);
        }
    })
}

/// Map a channel count to an `opus::Channels` value.
fn opus_channels(channels: u16) -> Result<opus::Channels, Box<dyn std::error::Error>> {
    match channels {
        1 => Ok(opus::Channels::Mono),
        2 => Ok(opus::Channels::Stereo),
        _ => Err(format!("unsupported channel count: {}", channels).into()),
    }
}

/// Look up an audio device by name (or fall back to the default device).
///
/// When `is_input` is true the function searches input devices and falls back
/// to the default input device; otherwise it uses output devices.  Returns
/// `None` when the device cannot be found (the caller should retry after a
/// delay).
fn find_device(
    host: &cpal::Host,
    device_name: &Option<String>,
    is_input: bool,
) -> Option<cpal::Device> {
    use cpal::traits::{DeviceTrait, HostTrait};

    let direction = if is_input { "capture" } else { "playback" };

    if let Some(ref name) = device_name {
        let devices_result = if is_input {
            host.input_devices()
        } else {
            host.output_devices()
        };
        match devices_result {
            Ok(mut devs) => match devs.find(|d| d.name().map(|n| n == *name).unwrap_or(false)) {
                Some(d) => Some(d),
                None => {
                    warn!("Audio {}: device '{}' not found, retrying", direction, name);
                    None
                }
            },
            Err(e) => {
                warn!(
                    "Audio {}: failed to enumerate devices, retrying: {}",
                    direction, e
                );
                None
            }
        }
    } else {
        let default = if is_input {
            host.default_input_device()
        } else {
            host.default_output_device()
        };
        match default {
            Some(d) => Some(d),
            None => {
                warn!(
                    "Audio {}: no default {} device, retrying",
                    direction,
                    if is_input { "input" } else { "output" }
                );
                None
            }
        }
    }
}

#[allow(clippy::too_many_arguments)]
fn run_capture(
    sample_rate: u32,
    channels: u16,
    frame_duration_ms: u16,
    bitrate_bps: u32,
    device_name: Option<String>,
    tx: broadcast::Sender<Bytes>,
    pcm_tx: Option<broadcast::Sender<Vec<f32>>>,
    shutdown_rx: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
    use cpal::traits::{DeviceTrait, StreamTrait};
    use std::sync::mpsc::{RecvTimeoutError, TryRecvError as StdTryRecvError};

    let config = cpal::StreamConfig {
        channels,
        sample_rate: cpal::SampleRate(sample_rate),
        buffer_size: cpal::BufferSize::Default,
    };

    let frame_samples =
        (sample_rate as usize * frame_duration_ms as usize / 1000) * channels as usize;

    let opus_ch = opus_channels(channels)?;

    let mut encoder = opus::Encoder::new(sample_rate, opus_ch, opus::Application::Audio)?;
    encoder.set_bitrate(opus::Bitrate::Bits(bitrate_bps as i32))?;
    encoder.set_complexity(5)?;

    // Start paused — only capture when clients are connected
    info!(
        "Audio capture: ready ({}Hz, {} ch, {}ms frames)",
        sample_rate, channels, frame_duration_ms
    );

    let input_err_logger = Arc::new(StreamErrorLogger::new("Audio input stream error"));
    let mut pcm_buf: Vec<f32> = Vec::with_capacity(frame_samples * 2);
    let mut opus_buf = vec![0u8; 4096];
    let mut capturing = false;

    loop {
        if *shutdown_rx.borrow() {
            info!("Audio capture: shutdown signal received, exiting");
            return Ok(());
        }

        // Re-enumerate the device on every recovery cycle: after POLLERR the
        // existing device handle can be stale (especially for USB audio).
        let host = cpal::default_host();
        let device = match find_device(&host, &device_name, true) {
            Some(d) => d,
            None => {
                std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
                continue;
            }
        };
        info!(
            "Audio capture: using device '{}'",
            device.name().unwrap_or_else(|_| "unknown".into())
        );
        let (sample_tx, sample_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
        let (stream_err_tx, stream_err_rx) = std::sync::mpsc::sync_channel::<()>(1);
        let stream_failed = Arc::new(AtomicBool::new(false));
        let stream = match device.build_input_stream(
            &config,
            move |data: &[f32], _: &cpal::InputCallbackInfo| {
                let _ = sample_tx.try_send(data.to_vec());
            },
            {
                let input_err_logger = input_err_logger.clone();
                let stream_failed = stream_failed.clone();
                let stream_err_tx = stream_err_tx.clone();
                move |err| {
                    // swap ensures only the first error does expensive work;
                    // subsequent callbacks (can fire millions/s on ALSA EPIPE)
                    // return immediately after a single atomic op.
                    if !stream_failed.swap(true, Ordering::SeqCst) {
                        input_err_logger.log(&err.to_string());
                        let _ = stream_err_tx.try_send(());
                    }
                }
            },
            None,
        ) {
            Ok(stream) => stream,
            Err(err) => {
                warn!(
                    "Audio capture: failed to open input stream, retrying: {}",
                    err
                );
                std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
                continue;
            }
        };

        if capturing {
            let _ = stream.play();
        }

        loop {
            if *shutdown_rx.borrow() {
                info!("Audio capture: shutdown signal received, exiting");
                return Ok(());
            }

            match stream_err_rx.try_recv() {
                Ok(()) | Err(StdTryRecvError::Disconnected) => {
                    warn!("Audio capture: backend stream error, recreating");
                    break;
                }
                Err(StdTryRecvError::Empty) => {}
            }

            if stream_failed.load(Ordering::SeqCst) {
                warn!("Audio capture: backend stream error, recreating");
                break;
            }

            let has_receivers =
                tx.receiver_count() > 0 || pcm_tx.as_ref().is_some_and(|p| p.receiver_count() > 0);

            if has_receivers && !capturing {
                let _ = stream.play();
                capturing = true;
                info!("Audio capture: started");
            } else if !has_receivers && capturing {
                let _ = stream.pause();
                capturing = false;
                pcm_buf.clear();
                while sample_rx.try_recv().is_ok() {}
                info!("Audio capture: paused (no listeners)");
            }

            if !capturing {
                std::thread::sleep(std::time::Duration::from_millis(100));
                continue;
            }

            match sample_rx.recv_timeout(std::time::Duration::from_millis(200)) {
                Ok(samples) => {
                    pcm_buf.extend_from_slice(&samples);
                    while pcm_buf.len() >= frame_samples {
                        let frame: Vec<f32> = pcm_buf.drain(..frame_samples).collect();
                        if let Some(ref pcm_tx) = pcm_tx {
                            let _ = pcm_tx.send(frame.clone());
                        }
                        match encoder.encode_float(&frame, &mut opus_buf) {
                            Ok(len) => {
                                let packet = Bytes::copy_from_slice(&opus_buf[..len]);
                                let _ = tx.send(packet);
                            }
                            Err(e) => {
                                warn!("Opus encode error: {}", e);
                            }
                        }
                    }
                }
                Err(RecvTimeoutError::Timeout) => {}
                Err(RecvTimeoutError::Disconnected) => {
                    warn!("Audio capture: callback channel disconnected, recreating");
                    break;
                }
            }
        }

        if capturing {
            let _ = stream.pause();
            capturing = false;
            pcm_buf.clear();
        }
        std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
    }
}

/// Spawn the audio playback task.
///
/// Receives Opus packets, decodes them, and plays through cpal output.
pub fn spawn_audio_playback(
    cfg: &AudioConfig,
    rx: mpsc::Receiver<Bytes>,
    shutdown_rx: watch::Receiver<bool>,
) -> std::thread::JoinHandle<()> {
    let sample_rate = cfg.sample_rate;
    let channels = cfg.channels as u16;
    let frame_duration_ms = cfg.frame_duration_ms;
    let device_name = cfg.device.clone();

    std::thread::spawn(move || {
        if let Err(e) = run_playback(
            sample_rate,
            channels,
            frame_duration_ms,
            device_name,
            rx,
            shutdown_rx,
        ) {
            error!("Audio playback thread error: {}", e);
        }
    })
}

fn run_playback(
    sample_rate: u32,
    channels: u16,
    frame_duration_ms: u16,
    device_name: Option<String>,
    mut rx: mpsc::Receiver<Bytes>,
    shutdown_rx: watch::Receiver<bool>,
) -> Result<(), Box<dyn std::error::Error>> {
    use cpal::traits::{DeviceTrait, StreamTrait};
    use std::sync::mpsc::TryRecvError as StdTryRecvError;
    use tokio::sync::mpsc::error::TryRecvError as TokioTryRecvError;

    let config = cpal::StreamConfig {
        channels,
        sample_rate: cpal::SampleRate(sample_rate),
        buffer_size: cpal::BufferSize::Default,
    };

    let frame_samples =
        (sample_rate as usize * frame_duration_ms as usize / 1000) * channels as usize;

    let opus_ch = opus_channels(channels)?;

    let mut decoder = opus::Decoder::new(sample_rate, opus_ch)?;

    let ring = std::sync::Arc::new(std::sync::Mutex::new(
        std::collections::VecDeque::<f32>::with_capacity(frame_samples * 8),
    ));
    let ring_writer = ring.clone();

    // Start paused — only play when TX packets arrive
    info!("Audio playback: ready ({}Hz, {} ch)", sample_rate, channels);

    let output_err_logger = Arc::new(StreamErrorLogger::new("Audio output stream error"));
    let mut pcm_buf = vec![0f32; frame_samples];
    let mut playing = false;
    let mut channel_closed = false;

    loop {
        if *shutdown_rx.borrow() {
            info!("Audio playback: shutdown signal received, exiting");
            return Ok(());
        }

        // Re-enumerate the device on every recovery cycle: after POLLERR the
        // existing device handle can be stale (especially for USB audio).
        let host = cpal::default_host();
        let device = match find_device(&host, &device_name, false) {
            Some(d) => d,
            None => {
                std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
                continue;
            }
        };
        info!(
            "Audio playback: using device '{}'",
            device.name().unwrap_or_else(|_| "unknown".into())
        );
        let (stream_err_tx, stream_err_rx) = std::sync::mpsc::sync_channel::<()>(1);
        let stream_failed = Arc::new(AtomicBool::new(false));
        let stream = match device.build_output_stream(
            &config,
            {
                let ring = ring.clone();
                move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
                    let mut ring = ring.lock().unwrap_or_else(|e| e.into_inner());
                    for sample in data.iter_mut() {
                        *sample = ring.pop_front().unwrap_or(0.0);
                    }
                }
            },
            {
                let output_err_logger = output_err_logger.clone();
                let stream_failed = stream_failed.clone();
                let stream_err_tx = stream_err_tx.clone();
                move |err| {
                    // swap ensures only the first error does expensive work;
                    // subsequent callbacks (can fire millions/s on ALSA EPIPE)
                    // return immediately after a single atomic op.
                    if !stream_failed.swap(true, Ordering::SeqCst) {
                        output_err_logger.log(&err.to_string());
                        let _ = stream_err_tx.try_send(());
                    }
                }
            },
            None,
        ) {
            Ok(stream) => stream,
            Err(err) => {
                warn!(
                    "Audio playback: failed to open output stream, retrying: {}",
                    err
                );
                std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
                continue;
            }
        };

        if playing {
            if let Err(e) = stream.play() {
                warn!("Audio playback: stream.play failed, recreating: {}", e);
                std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
                continue;
            }
        }

        loop {
            if *shutdown_rx.borrow() {
                info!("Audio playback: shutdown signal received, exiting");
                return Ok(());
            }

            match stream_err_rx.try_recv() {
                Ok(()) | Err(StdTryRecvError::Disconnected) => {
                    warn!("Audio playback: backend stream error, recreating");
                    break;
                }
                Err(StdTryRecvError::Empty) => {}
            }

            if stream_failed.load(Ordering::SeqCst) {
                warn!("Audio playback: backend stream error, recreating");
                break;
            }

            match rx.try_recv() {
                Ok(packet) => {
                    if !playing {
                        if let Err(e) = stream.play() {
                            warn!("Audio playback: stream.play failed, recreating: {}", e);
                            break;
                        }
                        playing = true;
                        info!("Audio playback: started");
                    }

                    match decoder.decode_float(&packet, &mut pcm_buf, false) {
                        Ok(decoded) => {
                            let mut ring = ring_writer.lock().unwrap_or_else(|e| e.into_inner());
                            ring.extend(&pcm_buf[..decoded * channels as usize]);
                        }
                        Err(e) => {
                            warn!("Opus decode error: {}", e);
                        }
                    }

                    if rx.is_empty() {
                        std::thread::sleep(std::time::Duration::from_millis(
                            frame_duration_ms as u64 * 2,
                        ));
                        if rx.is_empty() {
                            let _ = stream.pause();
                            playing = false;
                            ring_writer
                                .lock()
                                .unwrap_or_else(|e| e.into_inner())
                                .clear();
                            info!("Audio playback: paused (idle)");
                            if channel_closed {
                                return Ok(());
                            }
                        }
                    }
                }
                Err(TokioTryRecvError::Empty) => {
                    if channel_closed && !playing {
                        return Ok(());
                    }
                    std::thread::sleep(std::time::Duration::from_millis(20));
                }
                Err(TokioTryRecvError::Disconnected) => {
                    channel_closed = true;
                    if !playing {
                        return Ok(());
                    }
                    std::thread::sleep(std::time::Duration::from_millis(20));
                }
            }
        }

        if playing {
            let _ = stream.pause();
            playing = false;
        }
        ring_writer
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .clear();

        if channel_closed {
            return Ok(());
        }
        std::thread::sleep(AUDIO_STREAM_RECOVERY_DELAY);
    }
}

/// Run the APRS decoder task. Only processes PCM when rig mode is PKT.
pub async fn run_aprs_decoder(
    sample_rate: u32,
    channels: u16,
    pcm_rx: broadcast::Receiver<Vec<f32>>,
    state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
) {
    run_aprs_decoder_inner(
        "APRS",
        sample_rate,
        channels,
        pcm_rx,
        state_rx,
        decode_tx,
        decode_logs,
        histories,
        false,
    )
    .await;
}

pub async fn run_hf_aprs_decoder(
    sample_rate: u32,
    channels: u16,
    pcm_rx: broadcast::Receiver<Vec<f32>>,
    state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
) {
    run_aprs_decoder_inner(
        "HF APRS",
        sample_rate,
        channels,
        pcm_rx,
        state_rx,
        decode_tx,
        decode_logs,
        histories,
        true,
    )
    .await;
}

#[allow(clippy::too_many_arguments)]
async fn run_aprs_decoder_inner(
    label: &str,
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
    is_hf: bool,
) {
    info!(
        "{} decoder started ({}Hz, {} ch)",
        label, sample_rate, channels
    );

    let mut decoder = if is_hf {
        AprsDecoder::new_hf(sample_rate)
    } else {
        AprsDecoder::new(sample_rate)
    };
    let mut was_active = false;
    let mut last_reset_seq: u64 = 0;

    let mode_match = |state: &RigState| -> bool {
        if is_hf {
            matches!(state.status.mode, RigMode::DIG)
        } else {
            matches!(state.status.mode, RigMode::PKT)
        }
    };
    let get_reset_seq = |state: &RigState| -> u64 {
        if is_hf {
            state.reset_seqs.hf_aprs_decode_reset_seq
        } else {
            state.reset_seqs.aprs_decode_reset_seq
        }
    };
    let span_name = if is_hf {
        "hf_aprs_decode"
    } else {
        "aprs_decode"
    };

    let mut active = mode_match(&state_rx.borrow());

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = mode_match(&state);
                    if active {
                        pcm_rx = pcm_rx.resubscribe();
                    }
                    let seq = get_reset_seq(&state);
                    if seq != last_reset_seq {
                        last_reset_seq = seq;
                        decoder.reset();
                        info!("{} decoder reset (seq={})", label, last_reset_seq);
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        let reset_seq = get_reset_seq(&state_rx.borrow());
                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            decoder.reset();
                            info!("{} decoder reset (seq={})", label, last_reset_seq);
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }

                        let mut mono = downmix_if_needed(frame, channels);
                        apply_decode_audio_gate(&mut mono);

                        was_active = true;
                        let packets = tokio::task::block_in_place(|| {
                            let _span = info_span!(target: "trx_server::audio", "aprs_decode_inner", variant = span_name).entered();
                            decoder.process_samples(&mono)
                        });
                        let latest_reset_seq = get_reset_seq(&state_rx.borrow());
                        if latest_reset_seq != reset_seq {
                            last_reset_seq = latest_reset_seq;
                            decoder.reset();
                            info!("{} decoder reset (seq={})", label, last_reset_seq);
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }
                        for mut pkt in packets {
                            if let Some(logger) = decode_logs.as_ref() {
                                logger.log_aprs(&pkt);
                            }
                            if !pkt.crc_ok {
                                continue;
                            }
                            if pkt.ts_ms.is_none() {
                                pkt.ts_ms = Some(current_timestamp_ms());
                            }
                            if is_hf {
                                histories.record_hf_aprs_packet(pkt.clone());
                                let _ = decode_tx.send(DecodedMessage::HfAprs(pkt));
                            } else {
                                histories.record_aprs_packet(pkt.clone());
                                let _ = decode_tx.send(DecodedMessage::Aprs(pkt));
                            }
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("{} decoder: dropped {} PCM frames", label, n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = mode_match(&state);
                        let seq = get_reset_seq(&state);
                        if seq != last_reset_seq {
                            last_reset_seq = seq;
                            decoder.reset();
                            info!("{} decoder reset (seq={})", label, last_reset_seq);
                        }
                        if !active && was_active {
                            decoder.reset();
                            was_active = false;
                        }
                        if active {
                            pcm_rx = pcm_rx.resubscribe();
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

fn downmix_if_needed(frame: Vec<f32>, channels: u16) -> Vec<f32> {
    if channels <= 1 {
        return frame;
    }

    let num_frames = frame.len() / channels as usize;
    let mut mono = Vec::with_capacity(num_frames);
    for i in 0..num_frames {
        mono.push(frame[i * channels as usize]);
    }
    mono
}

/// Run the AIS decoder task. Only processes PCM when rig mode is AIS.
pub async fn run_ais_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_a_rx: broadcast::Receiver<Vec<f32>>,
    mut pcm_b_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    histories: Arc<DecoderHistories>,
) {
    info!("AIS decoder started ({}Hz, {} ch)", sample_rate, channels);
    let mut decoder_a = AisDecoder::new(sample_rate);
    let mut decoder_b = AisDecoder::new(sample_rate);
    let mut was_active = false;
    let mut active = matches!(state_rx.borrow().status.mode, RigMode::AIS);

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = matches!(state.status.mode, RigMode::AIS);
                    if active {
                        pcm_a_rx = pcm_a_rx.resubscribe();
                        pcm_b_rx = pcm_b_rx.resubscribe();
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_a_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        was_active = true;
                        let mono = downmix_if_needed(frame, channels);
                        let messages =
                            tokio::task::block_in_place(|| {
                                let _span = info_span!("ais_decode_a").entered();
                                decoder_a.process_samples(&mono, "A")
                            });
                        for mut msg in messages {
                            if msg.ts_ms.is_none() {
                                msg.ts_ms = Some(current_timestamp_ms());
                            }
                            histories.record_ais_message(msg.clone());
                            let _ = decode_tx.send(DecodedMessage::Ais(msg));
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("AIS decoder A: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            recv = pcm_b_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        was_active = true;
                        let mono = downmix_if_needed(frame, channels);
                        let messages =
                            tokio::task::block_in_place(|| {
                                let _span = info_span!("ais_decode_b").entered();
                                decoder_b.process_samples(&mono, "B")
                            });
                        for mut msg in messages {
                            if msg.ts_ms.is_none() {
                                msg.ts_ms = Some(current_timestamp_ms());
                            }
                            histories.record_ais_message(msg.clone());
                            let _ = decode_tx.send(DecodedMessage::Ais(msg));
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("AIS decoder B: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = matches!(state.status.mode, RigMode::AIS);
                        if !active && was_active {
                            decoder_a.reset();
                            decoder_b.reset();
                            was_active = false;
                        }
                        if active {
                            pcm_a_rx = pcm_a_rx.resubscribe();
                            pcm_b_rx = pcm_b_rx.resubscribe();
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

/// Run the VDES decoder task. Only processes IQ when rig mode is VDES.
pub async fn run_vdes_decoder(
    sample_rate: u32,
    mut iq_rx: broadcast::Receiver<Vec<Complex<f32>>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    histories: Arc<DecoderHistories>,
) {
    info!("VDES decoder started ({}Hz complex baseband)", sample_rate);
    let mut decoder = VdesDecoder::new(sample_rate);
    let mut was_active = false;
    let mut active = matches!(state_rx.borrow().status.mode, RigMode::VDES);

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = matches!(state.status.mode, RigMode::VDES);
                    if active {
                        iq_rx = iq_rx.resubscribe();
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = iq_rx.recv() => {
                match recv {
                    Ok(block) => {
                        was_active = true;
                        let messages =
                            tokio::task::block_in_place(|| {
                                let _span = info_span!("vdes_decode").entered();
                                decoder.process_samples(&block, "Main")
                            });
                        for mut msg in messages {
                            if msg.ts_ms.is_none() {
                                msg.ts_ms = Some(current_timestamp_ms());
                            }
                            histories.record_vdes_message(msg.clone());
                            let _ = decode_tx.send(DecodedMessage::Vdes(msg));
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("VDES decoder: dropped {} IQ blocks", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = matches!(state.status.mode, RigMode::VDES);
                        if !active && was_active {
                            decoder.reset();
                            was_active = false;
                        }
                        if active {
                            iq_rx = iq_rx.resubscribe();
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

/// Run the CW decoder task. Only processes PCM when rig mode is CW or CWR.
pub async fn run_cw_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
) {
    info!("CW decoder started ({}Hz, {} ch)", sample_rate, channels);
    let mut decoder = CwDecoder::new(sample_rate);
    let mut was_active = false;
    let mut last_reset_seq: u64 = 0;
    let mut active = state_rx.borrow().decoders.cw_decode_enabled
        && matches!(state_rx.borrow().status.mode, RigMode::CW | RigMode::CWR);
    let mut last_auto = state_rx.borrow().cw_auto;
    let mut last_wpm = state_rx.borrow().cw_wpm;
    let mut last_tone = state_rx.borrow().cw_tone_hz;
    decoder.set_auto(last_auto);
    decoder.set_wpm(last_wpm);
    decoder.set_tone_hz(last_tone);

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = state.decoders.cw_decode_enabled
                        && matches!(state.status.mode, RigMode::CW | RigMode::CWR);
                    if active {
                        pcm_rx = pcm_rx.resubscribe();
                    }
                    if state.cw_auto != last_auto {
                        last_auto = state.cw_auto;
                        decoder.set_auto(last_auto);
                    }
                    if state.cw_wpm != last_wpm {
                        last_wpm = state.cw_wpm;
                        decoder.set_wpm(last_wpm);
                    }
                    if state.cw_tone_hz != last_tone {
                        last_tone = state.cw_tone_hz;
                        decoder.set_tone_hz(last_tone);
                    }
                    if state.reset_seqs.cw_decode_reset_seq != last_reset_seq {
                        last_reset_seq = state.reset_seqs.cw_decode_reset_seq;
                        decoder.reset();
                        info!("CW decoder reset (seq={})", last_reset_seq);
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        let (process_enabled, cw_auto, cw_wpm, cw_tone_hz, reset_seq) = {
                            let state = state_rx.borrow();
                            (
                                state.decoders.cw_decode_enabled
                                    && matches!(state.status.mode, RigMode::CW | RigMode::CWR),
                                state.cw_auto,
                                state.cw_wpm,
                                state.cw_tone_hz,
                                state.reset_seqs.cw_decode_reset_seq,
                            )
                        };
                        if cw_auto != last_auto {
                            last_auto = cw_auto;
                            decoder.set_auto(last_auto);
                        }
                        if cw_wpm != last_wpm {
                            last_wpm = cw_wpm;
                            decoder.set_wpm(last_wpm);
                        }
                        if cw_tone_hz != last_tone {
                            last_tone = cw_tone_hz;
                            decoder.set_tone_hz(last_tone);
                        }
                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            decoder.reset();
                            info!("CW decoder reset (seq={})", last_reset_seq);
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }
                        if !process_enabled {
                            if was_active {
                                decoder.reset();
                                was_active = false;
                            }
                            active = false;
                            continue;
                        }

                        // Downmix to mono if stereo
                        let mono = if channels > 1 {
                            let num_frames = frame.len() / channels as usize;
                            let mut mono = Vec::with_capacity(num_frames);
                            for i in 0..num_frames {
                                mono.push(frame[i * channels as usize]);
                            }
                            mono
                        } else {
                            frame
                        };
                        was_active = true;
                        let events = tokio::task::block_in_place(|| {
                            let _span = info_span!("cw_decode").entered();
                            decoder.process_samples(&mono)
                        });
                        let latest_reset_seq = state_rx.borrow().reset_seqs.cw_decode_reset_seq;
                        if latest_reset_seq != reset_seq {
                            last_reset_seq = latest_reset_seq;
                            decoder.reset();
                            info!("CW decoder reset (seq={})", last_reset_seq);
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }
                        for evt in events {
                            if let Some(logger) = decode_logs.as_ref() {
                                logger.log_cw(&evt);
                            }
                            histories.record_cw_event(evt.clone());
                            let _ = decode_tx.send(DecodedMessage::Cw(evt));
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("CW decoder: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = state.decoders.cw_decode_enabled
                            && matches!(state.status.mode, RigMode::CW | RigMode::CWR);
                        if state.cw_auto != last_auto {
                            last_auto = state.cw_auto;
                            decoder.set_auto(last_auto);
                        }
                        if state.cw_wpm != last_wpm {
                            last_wpm = state.cw_wpm;
                            decoder.set_wpm(last_wpm);
                        }
                        if state.cw_tone_hz != last_tone {
                            last_tone = state.cw_tone_hz;
                            decoder.set_tone_hz(last_tone);
                        }
                        if state.reset_seqs.cw_decode_reset_seq != last_reset_seq {
                            last_reset_seq = state.reset_seqs.cw_decode_reset_seq;
                            decoder.reset();
                            info!("CW decoder reset (seq={})", last_reset_seq);
                        }
                        if !active && was_active {
                            decoder.reset();
                            was_active = false;
                        }
                        if active {
                            pcm_rx = pcm_rx.resubscribe();
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

fn downmix_mono(frame: Vec<f32>, channels: u16) -> Vec<f32> {
    if channels <= 1 {
        return frame;
    }
    let num_frames = frame.len() / channels as usize;
    let mut mono = Vec::with_capacity(num_frames);
    for i in 0..num_frames {
        mono.push(frame[i * channels as usize]);
    }
    mono
}

fn frame_rms(samples: &[f32]) -> f32 {
    if samples.is_empty() {
        return 0.0;
    }
    let mut sum_sq = 0.0f32;
    for &sample in samples {
        sum_sq += sample * sample;
    }
    (sum_sq / samples.len() as f32).sqrt()
}

fn apply_decode_audio_gate(samples: &mut [f32]) -> bool {
    if frame_rms(samples) >= DECODE_AUDIO_GATE_RMS {
        return false;
    }
    for sample in samples {
        *sample = 0.0;
    }
    true
}

fn resample_to_12k(samples: &[f32], sample_rate: u32) -> Option<Vec<f32>> {
    if sample_rate == FT8_SAMPLE_RATE {
        return Some(samples.to_vec());
    }
    if !sample_rate.is_multiple_of(FT8_SAMPLE_RATE) {
        return None;
    }
    let factor = (sample_rate / FT8_SAMPLE_RATE) as usize;
    if factor == 0 {
        return None;
    }
    let mut out = Vec::with_capacity(samples.len() / factor);
    for chunk in samples.chunks_exact(factor) {
        let mut acc = 0.0f32;
        for &s in chunk {
            acc += s;
        }
        out.push(acc / factor as f32);
    }
    Some(out)
}

/// Run the FT8 decoder task. Only processes PCM when rig mode is DIG/USB and enabled.
pub async fn run_ft8_decoder(
    sample_rate: u32,
    channels: u16,
    pcm_rx: broadcast::Receiver<Vec<f32>>,
    state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
) {
    run_ftx_decoder_inner(
        "FT8",
        sample_rate,
        channels,
        pcm_rx,
        state_rx,
        decode_tx,
        decode_logs,
        histories,
        false,
    )
    .await;
}

/// Run the FT4 decoder task. Mirrors FT8 but uses FT4 protocol (7.5s slots).
pub async fn run_ft4_decoder(
    sample_rate: u32,
    channels: u16,
    pcm_rx: broadcast::Receiver<Vec<f32>>,
    state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    histories: Arc<DecoderHistories>,
) {
    run_ftx_decoder_inner(
        "FT4",
        sample_rate,
        channels,
        pcm_rx,
        state_rx,
        decode_tx,
        None,
        histories,
        true,
    )
    .await;
}

/// Shared implementation for FT8 and FT4 decoder tasks.
#[allow(clippy::too_many_arguments)]
async fn run_ftx_decoder_inner(
    label: &str,
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
    is_ft4: bool,
) {
    info!(
        "{} decoder started ({}Hz, {} ch)",
        label, sample_rate, channels
    );

    let mut decoder = {
        let result = if is_ft4 {
            Ft8Decoder::new_ft4(FT8_SAMPLE_RATE)
        } else {
            Ft8Decoder::new(FT8_SAMPLE_RATE)
        };
        match result {
            Ok(decoder) => decoder,
            Err(err) => {
                warn!("{} decoder init failed: {}", label, err);
                return;
            }
        }
    };

    let is_enabled = |state: &RigState| -> bool {
        if is_ft4 {
            state.decoders.ft4_decode_enabled
        } else {
            state.decoders.ft8_decode_enabled
        }
    };
    let get_reset_seq = |state: &RigState| -> u64 {
        if is_ft4 {
            state.reset_seqs.ft4_decode_reset_seq
        } else {
            state.reset_seqs.ft8_decode_reset_seq
        }
    };
    let span_name = if is_ft4 { "ft4_decode" } else { "ft8_decode" };

    let mut last_reset_seq: u64 = 0;
    let mut active = is_enabled(&state_rx.borrow())
        && matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB);
    let mut sample_buf: Vec<f32> = Vec::new();
    let mut last_slot: i64 = -1;

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = is_enabled(&state)
                        && matches!(state.status.mode, RigMode::DIG | RigMode::USB);
                    if active {
                        pcm_rx = pcm_rx.resubscribe();
                    }
                    let seq = get_reset_seq(&state);
                    if seq != last_reset_seq {
                        last_reset_seq = seq;
                        decoder.reset();
                        sample_buf.clear();
                    }
                    last_slot = -1;
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        // Compute current slot; FT8 uses 15s slots, FT4 uses 7.5s slots
                        let slot = if is_ft4 {
                            let now_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                                Ok(dur) => dur.as_millis() as i64,
                                Err(_) => 0,
                            };
                            now_ms / 7_500
                        } else {
                            let now_s = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                                Ok(dur) => dur.as_secs() as i64,
                                Err(_) => 0,
                            };
                            now_s / 15
                        };
                        if slot != last_slot {
                            last_slot = slot;
                            decoder.reset();
                            sample_buf.clear();
                        }

                        let reset_seq = get_reset_seq(&state_rx.borrow());
                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            decoder.reset();
                            sample_buf.clear();
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }

                        let mut mono = downmix_mono(frame, channels);
                        apply_decode_audio_gate(&mut mono);
                        let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                            warn!("{} decoder: unsupported sample rate {}", label, sample_rate);
                            break;
                        };
                        sample_buf.extend_from_slice(&resampled);

                        while sample_buf.len() >= decoder.block_size() {
                            let block: Vec<f32> = sample_buf.drain(..decoder.block_size()).collect();
                            let results = tokio::task::block_in_place(|| {
                                let _span = info_span!(target: "trx_server::audio", "ftx_decode_inner", variant = span_name).entered();
                                decoder.process_block(&block);
                                decoder.decode_if_ready(100)
                            });
                            let latest_reset_seq = get_reset_seq(&state_rx.borrow());
                            if latest_reset_seq != reset_seq {
                                last_reset_seq = latest_reset_seq;
                                decoder.reset();
                                sample_buf.clear();
                                pcm_rx = pcm_rx.resubscribe();
                                continue;
                            }
                            if !results.is_empty() {
                                for res in results {
                                    let ts_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                                        Ok(dur) => dur.as_millis() as i64,
                                        Err(_) => 0,
                                    };
                                    let base_freq_hz = state_rx.borrow().status.freq.hz as f64;
                                    let abs_freq_hz = base_freq_hz + res.freq_hz as f64;
                                    let msg = Ft8Message {
                                        rig_id: None,
                                        ts_ms,
                                        snr_db: res.snr_db,
                                        dt_s: res.dt_s,
                                        freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
                                            abs_freq_hz as f32
                                        } else {
                                            res.freq_hz
                                        },
                                        message: res.text,
                                    };
                                    if is_ft4 {
                                        histories.record_ft4_message(msg.clone());
                                        let _ = decode_tx.send(DecodedMessage::Ft4(msg));
                                    } else {
                                        histories.record_ft8_message(msg.clone());
                                        if let Some(logger) = decode_logs.as_ref() {
                                            logger.log_ft8(&msg);
                                        }
                                        let _ = decode_tx.send(DecodedMessage::Ft8(msg));
                                    }
                                }
                            }
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("{} decoder: dropped {} PCM frames", label, n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = is_enabled(&state)
                            && matches!(state.status.mode, RigMode::DIG | RigMode::USB);
                        let seq = get_reset_seq(&state);
                        if seq != last_reset_seq {
                            last_reset_seq = seq;
                            decoder.reset();
                            sample_buf.clear();
                        }
                        if !active {
                            decoder.reset();
                            sample_buf.clear();
                            last_slot = -1;
                        } else {
                            pcm_rx = pcm_rx.resubscribe();
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

/// Run the FT2 decoder task. Mirrors FT4 but uses FT2 protocol timing.
#[cfg(feature = "ft2")]
pub async fn run_ft2_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    histories: Arc<DecoderHistories>,
) {
    info!("FT2 decoder started ({}Hz, {} ch)", sample_rate, channels);
    let mut decoder = match Ft8Decoder::new_ft2(FT8_SAMPLE_RATE) {
        Ok(decoder) => decoder,
        Err(err) => {
            warn!("FT2 decoder init failed: {}", err);
            return;
        }
    };
    let mut last_reset_seq: u64 = 0;
    let mut active = state_rx.borrow().decoders.ft2_decode_enabled
        && matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB);
    let mut ft2_buf: Vec<f32> = Vec::new();
    let mut pending_decode_samples: usize = 0;
    let mut recent_decodes: HashMap<String, Instant> = HashMap::new();

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = state.decoders.ft2_decode_enabled
                        && matches!(state.status.mode, RigMode::DIG | RigMode::USB);
                    if active {
                        pcm_rx = pcm_rx.resubscribe();
                    }
                    if state.reset_seqs.ft2_decode_reset_seq != last_reset_seq {
                        last_reset_seq = state.reset_seqs.ft2_decode_reset_seq;
                        decoder.reset();
                        ft2_buf.clear();
                        pending_decode_samples = 0;
                        recent_decodes.clear();
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        let reset_seq = {
                            let state = state_rx.borrow();
                            state.reset_seqs.ft2_decode_reset_seq
                        };
                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            decoder.reset();
                            ft2_buf.clear();
                            pending_decode_samples = 0;
                            recent_decodes.clear();
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }

                        let mut mono = downmix_mono(frame, channels);
                        apply_decode_audio_gate(&mut mono);
                        let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                            warn!("FT2 decoder: unsupported sample rate {}", sample_rate);
                            break;
                        };
                        ft2_buf.extend_from_slice(&resampled);
                        pending_decode_samples += resampled.len();
                        retain_ft2_window(&mut ft2_buf);

                        while pending_decode_samples >= FT2_ASYNC_TRIGGER_SAMPLES
                            && ft2_buf.len() >= FT2_ASYNC_BUFFER_SAMPLES
                        {
                            pending_decode_samples -= FT2_ASYNC_TRIGGER_SAMPLES;
                            let results = tokio::task::block_in_place(|| {
                                let _span = info_span!("ft2_decode").entered();
                                decode_ft2_window(&mut decoder, &ft2_buf, 100)
                            });
                            let latest_reset_seq = state_rx.borrow().reset_seqs.ft2_decode_reset_seq;
                            if latest_reset_seq != reset_seq {
                                last_reset_seq = latest_reset_seq;
                                decoder.reset();
                                ft2_buf.clear();
                                pending_decode_samples = 0;
                                recent_decodes.clear();
                                pcm_rx = pcm_rx.resubscribe();
                                continue;
                            }
                            if !results.is_empty() {
                                for res in results {
                                    let ts_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                                        Ok(dur) => dur.as_millis() as i64,
                                        Err(_) => 0,
                                    };
                                    let base_freq_hz = state_rx.borrow().status.freq.hz as f64;
                                    let abs_freq_hz = base_freq_hz + res.freq_hz as f64;
                                    let msg = Ft8Message {
                                        rig_id: None,
                                        ts_ms,
                                        snr_db: res.snr_db,
                                        dt_s: res.dt_s,
                                        freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
                                            abs_freq_hz as f32
                                        } else {
                                            res.freq_hz
                                        },
                                        message: res.text,
                                    };
                                    if !should_emit_ft2_decode(
                                        &mut recent_decodes,
                                        &msg.message,
                                        msg.freq_hz,
                                    ) {
                                        continue;
                                    }
                                    histories.record_ft2_message(msg.clone());
                                    let _ = decode_tx.send(DecodedMessage::Ft2(msg));
                                }
                            }
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("FT2 decoder: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = state.decoders.ft2_decode_enabled
                            && matches!(state.status.mode, RigMode::DIG | RigMode::USB);
                        if state.reset_seqs.ft2_decode_reset_seq != last_reset_seq {
                            last_reset_seq = state.reset_seqs.ft2_decode_reset_seq;
                            decoder.reset();
                            ft2_buf.clear();
                            pending_decode_samples = 0;
                            recent_decodes.clear();
                        }
                        if !active {
                            decoder.reset();
                            ft2_buf.clear();
                            pending_decode_samples = 0;
                            recent_decodes.clear();
                        } else {
                            pcm_rx = pcm_rx.resubscribe();
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

/// Run the WSPR decoder task. Mirrors FT8 lifecycle/slot behavior.
///
/// Note: decoding engine integration is intentionally staged; this task already
/// participates in enable/disable/reset flow and transport plumbing.
pub async fn run_wspr_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    decode_logs: Option<Arc<DecoderLoggers>>,
    histories: Arc<DecoderHistories>,
) {
    info!("WSPR decoder started ({}Hz, {} ch)", sample_rate, channels);
    let decoder = match WsprDecoder::new() {
        Ok(decoder) => decoder,
        Err(err) => {
            warn!("WSPR decoder init failed: {}", err);
            return;
        }
    };
    let mut last_reset_seq: u64 = 0;
    let mut active = state_rx.borrow().decoders.wspr_decode_enabled
        && matches!(state_rx.borrow().status.mode, RigMode::DIG | RigMode::USB);
    let mut slot_buf: Vec<f32> = Vec::new();
    let mut last_slot: i64 = -1;
    let slot_len_s: i64 = 120;

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = state.decoders.wspr_decode_enabled
                        && matches!(state.status.mode, RigMode::DIG | RigMode::USB);
                    if active {
                        pcm_rx = pcm_rx.resubscribe();
                    }
                    if state.reset_seqs.wspr_decode_reset_seq != last_reset_seq {
                        last_reset_seq = state.reset_seqs.wspr_decode_reset_seq;
                    }
                    slot_buf.clear();
                    last_slot = -1;
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                            Ok(dur) => dur.as_secs() as i64,
                            Err(_) => 0,
                        };
                        let slot = now / slot_len_s;
                        let reset_seq = {
                            let state = state_rx.borrow();
                            state.reset_seqs.wspr_decode_reset_seq
                        };
                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            slot_buf.clear();
                            last_slot = slot;
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }
                        if last_slot == -1 {
                            last_slot = slot;
                        } else if slot != last_slot {
                            let base_freq = state_rx.borrow().status.freq.hz;
                            let decode_results = tokio::task::block_in_place(|| {
                                let _span = info_span!("wspr_decode").entered();
                                decoder.decode_slot(&slot_buf, Some(base_freq))
                            });
                            let latest_reset_seq = state_rx.borrow().reset_seqs.wspr_decode_reset_seq;
                            if latest_reset_seq != reset_seq {
                                last_reset_seq = latest_reset_seq;
                                slot_buf.clear();
                                last_slot = slot;
                                pcm_rx = pcm_rx.resubscribe();
                                continue;
                            }
                            match decode_results {
                                Ok(results) => {
                                    for res in results {
                                        let ts_ms = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                                            Ok(dur) => dur.as_millis() as i64,
                                            Err(_) => 0,
                                        };
                                        let msg = WsprMessage {
                                            rig_id: None,
                                            ts_ms,
                                            snr_db: res.snr_db,
                                            dt_s: res.dt_s,
                                            freq_hz: res.freq_hz,
                                            message: res.message,
                                        };
                                        histories.record_wspr_message(msg.clone());
                                        if let Some(logger) = decode_logs.as_ref() {
                                            logger.log_wspr(&msg);
                                        }
                                        let _ = decode_tx.send(DecodedMessage::Wspr(msg));
                                    }
                                }
                                Err(err) => warn!("WSPR decode failed: {}", err),
                            }
                            slot_buf.clear();
                            last_slot = slot;
                        }
                        let latest_reset_seq = state_rx.borrow().reset_seqs.wspr_decode_reset_seq;
                        if latest_reset_seq != last_reset_seq {
                            last_reset_seq = latest_reset_seq;
                            slot_buf.clear();
                            last_slot = slot;
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }

                        let mut mono = downmix_mono(frame, channels);
                        apply_decode_audio_gate(&mut mono);
                        let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                            warn!("WSPR decoder: unsupported sample rate {}", sample_rate);
                            break;
                        };
                        slot_buf.extend_from_slice(&resampled);
                        if slot_buf.len() > decoder.slot_samples() {
                            let keep = decoder.slot_samples();
                            let drain = slot_buf.len().saturating_sub(keep);
                            if drain > 0 {
                                slot_buf.drain(..drain);
                            }
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("WSPR decoder: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = state.decoders.wspr_decode_enabled
                            && matches!(state.status.mode, RigMode::DIG | RigMode::USB);
                        if state.reset_seqs.wspr_decode_reset_seq != last_reset_seq {
                            last_reset_seq = state.reset_seqs.wspr_decode_reset_seq;
                            slot_buf.clear();
                            last_slot = -1;
                        }
                        if active {
                            pcm_rx = pcm_rx.resubscribe();
                        } else {
                            slot_buf.clear();
                            last_slot = -1;
                        }
                    }
                    Err(_) => break,
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Meteor-M LRPT decoder task
// ---------------------------------------------------------------------------

/// Decode Meteor-M LRPT satellite images from QPSK-demodulated baseband.
///
/// The task is idle until `state.decoders.lrpt_decode_enabled` becomes `true`.
/// When disabled (or 30 s of silence elapses with no new MCUs), the
/// accumulated image is saved and broadcast.
pub async fn run_lrpt_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    histories: Arc<DecoderHistories>,
    output_dir: std::path::PathBuf,
) {
    use trx_wxsat::lrpt::LrptDecoder;

    info!("LRPT decoder started ({}Hz, {} ch)", sample_rate, channels);
    let mut decoder = LrptDecoder::new(sample_rate);
    let mut last_reset_seq: u64 = 0;
    let mut active = state_rx.borrow().decoders.lrpt_decode_enabled
        && matches!(state_rx.borrow().status.mode, RigMode::FM);
    let mut pass_start_ms: i64 = 0;
    let mut last_mcu_at = tokio::time::Instant::now();

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active = state.decoders.lrpt_decode_enabled
                        && matches!(state.status.mode, RigMode::FM);
                    if active {
                        decoder.reset();
                        pass_start_ms = current_timestamp_ms();
                        last_mcu_at = tokio::time::Instant::now();
                        pcm_rx = pcm_rx.resubscribe();
                    }
                    if state.reset_seqs.lrpt_decode_reset_seq != last_reset_seq {
                        last_reset_seq = state.reset_seqs.lrpt_decode_reset_seq;
                        decoder.reset();
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        let silence_deadline = last_mcu_at + LRPT_PASS_SILENCE_TIMEOUT;

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        let reset_seq = state_rx.borrow().reset_seqs.lrpt_decode_reset_seq;
                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            decoder.reset();
                        }
                        let mono: Vec<f32> = if channels > 1 {
                            frame.chunks_exact(channels as usize).map(|ch| ch[0]).collect()
                        } else {
                            frame
                        };
                        let new_mcus = {
                            let _span = info_span!("lrpt_decode").entered();
                            decoder.process_samples(&mono)
                        };
                        if new_mcus > 0 {
                            last_mcu_at = tokio::time::Instant::now();
                            let _ = decode_tx.send(DecodedMessage::LrptProgress(
                                LrptProgress {
                                    rig_id: None,
                                    mcu_count: decoder.mcu_count(),
                                },
                            ));
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("LRPT decoder: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                if changed.is_ok() {
                    let (new_active, new_reset_seq) = {
                        let state = state_rx.borrow();
                        (state.decoders.lrpt_decode_enabled
                            && matches!(state.status.mode, RigMode::FM),
                         state.reset_seqs.lrpt_decode_reset_seq)
                    };
                    let was_active = active;
                    active = new_active;
                    if new_reset_seq != last_reset_seq {
                        last_reset_seq = new_reset_seq;
                        decoder.reset();
                    }
                    if was_active && !active {
                        let (slat, slon) = {
                            let s = state_rx.borrow();
                            (s.server_latitude, s.server_longitude)
                        };
                        finalize_lrpt_pass(
                            &mut decoder,
                            &output_dir,
                            &decode_tx,
                            &histories,
                            pass_start_ms,
                            slat,
                            slon,
                        ).await;
                    }
                } else {
                    break;
                }
            }
            _ = tokio::time::sleep_until(silence_deadline), if decoder.mcu_count() > 0 => {
                info!(
                    "LRPT: no new MCUs for {}s — finalising pass ({} MCUs)",
                    LRPT_PASS_SILENCE_TIMEOUT.as_secs(),
                    decoder.mcu_count()
                );
                let (slat, slon) = {
                    let s = state_rx.borrow();
                    (s.server_latitude, s.server_longitude)
                };
                finalize_lrpt_pass(
                    &mut decoder,
                    &output_dir,
                    &decode_tx,
                    &histories,
                    pass_start_ms,
                    slat,
                    slon,
                ).await;
            }
        }
    }
}

async fn finalize_lrpt_pass(
    decoder: &mut trx_wxsat::lrpt::LrptDecoder,
    output_dir: &std::path::Path,
    decode_tx: &broadcast::Sender<DecodedMessage>,
    histories: &Arc<DecoderHistories>,
    pass_start_ms: i64,
    station_lat: Option<f64>,
    station_lon: Option<f64>,
) {
    if decoder.mcu_count() < 2 {
        decoder.reset();
        return;
    }

    let pass_end_ms = current_timestamp_ms();

    let Some(lrpt_image) = decoder.finalize() else {
        decoder.reset();
        return;
    };

    let dt = chrono::Local::now();
    let filename = dt.format("%Y-%m-%d_%H-%M-%S-lrpt.png").to_string();
    let path = output_dir.join(&filename);

    if let Err(e) = std::fs::create_dir_all(output_dir) {
        warn!(
            "LRPT: failed to create output directory {:?}: {}",
            output_dir, e
        );
        decoder.reset();
        return;
    }

    match std::fs::write(&path, &lrpt_image.png) {
        Ok(()) => {
            info!(
                "LRPT: saved {} ({} MCUs, {} bytes) to {:?}",
                filename,
                lrpt_image.mcu_count,
                lrpt_image.png.len(),
                path
            );
            let sat_name = lrpt_image.satellite.map(|s| s.to_string());
            // Compute geographic bounds from SGP4 propagation
            let pass_geo = sat_name
                .as_deref()
                .and_then(|sat| {
                    trx_core::geo::compute_pass_geo(
                        sat,
                        pass_start_ms,
                        pass_end_ms,
                        station_lat,
                        station_lon,
                    )
                })
                .or_else(|| match (station_lat, station_lon) {
                    (Some(lat), Some(lon)) => Some(trx_core::geo::estimate_pass_geo_from_station(
                        pass_start_ms,
                        pass_end_ms,
                        lat,
                        lon,
                    )),
                    _ => None,
                });
            let (geo_bounds, ground_track) = match pass_geo {
                Some(geo) => (Some(geo.bounds), Some(geo.ground_track)),
                None => (None, None),
            };
            let img = LrptImage {
                rig_id: None,
                pass_start_ms,
                pass_end_ms,
                mcu_count: lrpt_image.mcu_count,
                path: path.to_string_lossy().into_owned(),
                ts_ms: Some(pass_end_ms),
                satellite: sat_name.clone(),
                channels: lrpt_image.channels.clone(),
                geo_bounds,
                ground_track,
            };
            if geo_bounds.is_some() {
                info!(
                    "LRPT: geo-referenced {} image overlay",
                    sat_name.as_deref().unwrap_or("unknown")
                );
            }
            histories.record_lrpt_image(img.clone());
            let _ = decode_tx.send(DecodedMessage::LrptImage(img));
        }
        Err(e) => {
            warn!("LRPT: failed to write {:?}: {}", path, e);
        }
    }

    decoder.reset();
}

// ---------------------------------------------------------------------------
// WEFAX decoder task
// ---------------------------------------------------------------------------

/// Run the WEFAX decoder task. Processes PCM when enabled and rig mode matches.
pub async fn run_wefax_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    mut state_rx: watch::Receiver<RigState>,
    decode_tx: broadcast::Sender<DecodedMessage>,
    histories: Arc<DecoderHistories>,
) {
    use trx_wefax::{WefaxConfig, WefaxDecoder, WefaxEvent};

    info!("WEFAX decoder started ({}Hz, {} ch)", sample_rate, channels);

    let wefax_output_dir = dirs::cache_dir()
        .unwrap_or_else(|| std::path::PathBuf::from(".cache"))
        .join("trx-rs")
        .join("wefax");
    let config = WefaxConfig {
        output_dir: Some(wefax_output_dir.to_string_lossy().into_owned()),
        ..WefaxConfig::default()
    };
    let mut decoder = WefaxDecoder::new(sample_rate, config);
    let mut was_active = false;
    let mut last_reset_seq: u64 = 0;

    let is_wefax_mode = |mode: &RigMode| {
        matches!(
            mode,
            RigMode::USB | RigMode::LSB | RigMode::AM | RigMode::DIG
        )
    };

    let mut active = state_rx.borrow().decoders.wefax_decode_enabled
        && is_wefax_mode(&state_rx.borrow().status.mode);

    loop {
        if !active {
            match state_rx.changed().await {
                Ok(()) => {
                    let state = state_rx.borrow();
                    active =
                        state.decoders.wefax_decode_enabled && is_wefax_mode(&state.status.mode);
                    if active {
                        pcm_rx = pcm_rx.resubscribe();
                    }
                }
                Err(_) => break,
            }
            continue;
        }

        tokio::select! {
            recv = pcm_rx.recv() => {
                match recv {
                    Ok(frame) => {
                        let (process_enabled, reset_seq) = {
                            let state = state_rx.borrow();
                            (
                                state.decoders.wefax_decode_enabled
                                    && is_wefax_mode(&state.status.mode),
                                state.reset_seqs.wefax_decode_reset_seq,
                            )
                        };

                        if reset_seq != last_reset_seq {
                            last_reset_seq = reset_seq;
                            // Reset saves any in-progress image.
                            for evt in decoder.reset() {
                                if let WefaxEvent::Complete(msg) = evt {
                                    histories.record_wefax_message(msg.clone());
                                    let _ = decode_tx.send(DecodedMessage::Wefax(msg));
                                }
                            }
                            info!("WEFAX decoder reset (seq={})", last_reset_seq);
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }

                        if !process_enabled {
                            if was_active {
                                for evt in decoder.reset() {
                                    if let WefaxEvent::Complete(msg) = evt {
                                        histories.record_wefax_message(msg.clone());
                                        let _ = decode_tx.send(DecodedMessage::Wefax(msg));
                                    }
                                }
                                was_active = false;
                            }
                            active = false;
                            continue;
                        }

                        let mono = if channels > 1 {
                            let num_frames = frame.len() / channels as usize;
                            let mut mono = Vec::with_capacity(num_frames);
                            for i in 0..num_frames {
                                mono.push(frame[i * channels as usize]);
                            }
                            mono
                        } else {
                            frame
                        };

                        was_active = true;
                        {
                            let state = state_rx.borrow();
                            decoder.set_tuning(
                                state.status.freq.hz,
                                &format!("{:?}", state.status.mode),
                            );
                        }
                        let events = tokio::task::block_in_place(|| {
                            let _span = info_span!("wefax_decode").entered();
                            decoder.process_samples(&mono)
                        });

                        let latest_reset_seq =
                            state_rx.borrow().reset_seqs.wefax_decode_reset_seq;
                        if latest_reset_seq != reset_seq {
                            last_reset_seq = latest_reset_seq;
                            for evt in decoder.reset() {
                                if let WefaxEvent::Complete(msg) = evt {
                                    histories.record_wefax_message(msg.clone());
                                    let _ = decode_tx.send(DecodedMessage::Wefax(msg));
                                }
                            }
                            info!("WEFAX decoder reset (seq={})", last_reset_seq);
                            pcm_rx = pcm_rx.resubscribe();
                            continue;
                        }

                        for evt in events {
                            match evt {
                                WefaxEvent::Progress(progress, _line_data) => {
                                    let _ = decode_tx.send(
                                        DecodedMessage::WefaxProgress(progress),
                                    );
                                }
                                WefaxEvent::Complete(msg) => {
                                    histories.record_wefax_message(msg.clone());
                                    let _ =
                                        decode_tx.send(DecodedMessage::Wefax(msg));
                                }
                            }
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(n)) => {
                        warn!("WEFAX decoder: dropped {} PCM frames", n);
                    }
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
            changed = state_rx.changed() => {
                match changed {
                    Ok(()) => {
                        let state = state_rx.borrow();
                        active = state.decoders.wefax_decode_enabled
                            && is_wefax_mode(&state.status.mode);
                    }
                    Err(_) => break,
                }
            }
        }
    }
    info!("WEFAX decoder stopped");
}

// ---------------------------------------------------------------------------
// Virtual-channel audio support
// ---------------------------------------------------------------------------

/// Commands sent from the reader loop to the RX writer task to subscribe or
/// unsubscribe a virtual channel's Opus audio stream.
enum VChanCmd {
    /// Start encoding the given channel's PCM and forwarding it to the client.
    Subscribe {
        uuid: Uuid,
        pcm_rx: tokio::sync::broadcast::Receiver<Vec<f32>>,
        send_audio: bool,
        background_decode: Option<BackgroundDecodeSpec>,
    },
    /// Stop forwarding audio for the given channel.
    Unsubscribe(Uuid),
}

#[derive(Clone, Debug)]
struct BackgroundDecodeSpec {
    base_freq_hz: u64,
    decoder_kinds: Vec<String>,
}

async fn run_background_hf_aprs_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background HF APRS decoder started ({}Hz, {} ch)",
        sample_rate, channels
    );
    let mut decoder = AprsDecoder::new_hf(sample_rate);

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let mut mono = downmix_if_needed(frame, channels);
                apply_decode_audio_gate(&mut mono);
                for mut pkt in decoder.process_samples(&mono) {
                    if !pkt.crc_ok {
                        continue;
                    }
                    if pkt.ts_ms.is_none() {
                        pkt.ts_ms = Some(current_timestamp_ms());
                    }
                    let _ = decode_tx.send(DecodedMessage::HfAprs(pkt));
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background HF APRS decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

async fn run_background_aprs_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background APRS decoder started ({}Hz, {} ch)",
        sample_rate, channels
    );
    let mut decoder = AprsDecoder::new(sample_rate);

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let mut mono = downmix_if_needed(frame, channels);
                apply_decode_audio_gate(&mut mono);
                for mut pkt in decoder.process_samples(&mono) {
                    if !pkt.crc_ok {
                        continue;
                    }
                    if pkt.ts_ms.is_none() {
                        pkt.ts_ms = Some(current_timestamp_ms());
                    }
                    let _ = decode_tx.send(DecodedMessage::Aprs(pkt));
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background APRS decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

async fn run_background_ais_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background AIS decoder started ({}Hz, {} ch)",
        sample_rate, channels
    );
    let mut decoder = AisDecoder::new(sample_rate);

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let mono = downmix_if_needed(frame, channels);
                for mut msg in decoder.process_samples(&mono, "A") {
                    if msg.ts_ms.is_none() {
                        msg.ts_ms = Some(current_timestamp_ms());
                    }
                    let _ = decode_tx.send(DecodedMessage::Ais(msg));
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background AIS decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

async fn run_background_ft8_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    base_freq_hz: u64,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background FT8 decoder started ({}Hz, {} ch @ {} Hz)",
        sample_rate, channels, base_freq_hz
    );
    let mut decoder = match Ft8Decoder::new(FT8_SAMPLE_RATE) {
        Ok(decoder) => decoder,
        Err(err) => {
            warn!("Background FT8 decoder init failed: {}", err);
            return;
        }
    };
    let mut ft8_buf: Vec<f32> = Vec::new();
    let mut last_slot: i64 = -1;
    let slot_len_s: i64 = 15;

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                    Ok(dur) => dur.as_secs() as i64,
                    Err(_) => 0,
                };
                let slot = now / slot_len_s;
                if slot != last_slot {
                    last_slot = slot;
                    decoder.reset();
                    ft8_buf.clear();
                }

                let mut mono = downmix_mono(frame, channels);
                apply_decode_audio_gate(&mut mono);
                let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                    warn!(
                        "Background FT8 decoder: unsupported sample rate {}",
                        sample_rate
                    );
                    break;
                };
                ft8_buf.extend_from_slice(&resampled);

                while ft8_buf.len() >= decoder.block_size() {
                    let block: Vec<f32> = ft8_buf.drain(..decoder.block_size()).collect();
                    let results = tokio::task::block_in_place(|| {
                        let _span = info_span!("ft8_decode").entered();
                        decoder.process_block(&block);
                        decoder.decode_if_ready(100)
                    });
                    for res in results {
                        let abs_freq_hz = base_freq_hz as f64 + res.freq_hz as f64;
                        let msg = Ft8Message {
                            rig_id: None,
                            ts_ms: current_timestamp_ms(),
                            snr_db: res.snr_db,
                            dt_s: res.dt_s,
                            freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
                                abs_freq_hz as f32
                            } else {
                                res.freq_hz
                            },
                            message: res.text,
                        };
                        let _ = decode_tx.send(DecodedMessage::Ft8(msg));
                    }
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background FT8 decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

async fn run_background_ft4_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    base_freq_hz: u64,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background FT4 decoder started ({}Hz, {} ch @ {} Hz)",
        sample_rate, channels, base_freq_hz
    );
    let mut decoder = match Ft8Decoder::new_ft4(FT8_SAMPLE_RATE) {
        Ok(decoder) => decoder,
        Err(err) => {
            warn!("Background FT4 decoder init failed: {}", err);
            return;
        }
    };
    let mut ft4_buf: Vec<f32> = Vec::new();
    let mut last_slot: i64 = -1;

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let now_ms =
                    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                        Ok(dur) => dur.as_millis() as i64,
                        Err(_) => 0,
                    };
                // FT4 slot period is 7.5s
                let slot = now_ms / 7_500;
                if slot != last_slot {
                    last_slot = slot;
                    decoder.reset();
                    ft4_buf.clear();
                }

                let mut mono = downmix_mono(frame, channels);
                apply_decode_audio_gate(&mut mono);
                let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                    warn!(
                        "Background FT4 decoder: unsupported sample rate {}",
                        sample_rate
                    );
                    break;
                };
                ft4_buf.extend_from_slice(&resampled);

                while ft4_buf.len() >= decoder.block_size() {
                    let block: Vec<f32> = ft4_buf.drain(..decoder.block_size()).collect();
                    let results = tokio::task::block_in_place(|| {
                        let _span = info_span!("ft4_decode").entered();
                        decoder.process_block(&block);
                        decoder.decode_if_ready(100)
                    });
                    for res in results {
                        let abs_freq_hz = base_freq_hz as f64 + res.freq_hz as f64;
                        let msg = Ft8Message {
                            rig_id: None,
                            ts_ms: current_timestamp_ms(),
                            snr_db: res.snr_db,
                            dt_s: res.dt_s,
                            freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
                                abs_freq_hz as f32
                            } else {
                                res.freq_hz
                            },
                            message: res.text,
                        };
                        let _ = decode_tx.send(DecodedMessage::Ft4(msg));
                    }
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background FT4 decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

#[cfg(feature = "ft2")]
async fn run_background_ft2_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    base_freq_hz: u64,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background FT2 decoder started ({}Hz, {} ch @ {} Hz)",
        sample_rate, channels, base_freq_hz
    );
    let mut decoder = match Ft8Decoder::new_ft2(FT8_SAMPLE_RATE) {
        Ok(decoder) => decoder,
        Err(err) => {
            warn!("Background FT2 decoder init failed: {}", err);
            return;
        }
    };
    let mut ft2_buf: Vec<f32> = Vec::new();
    let mut pending_decode_samples: usize = 0;
    let mut recent_decodes: HashMap<String, Instant> = HashMap::new();

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let mut mono = downmix_mono(frame, channels);
                apply_decode_audio_gate(&mut mono);
                let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                    warn!(
                        "Background FT2 decoder: unsupported sample rate {}",
                        sample_rate
                    );
                    break;
                };
                ft2_buf.extend_from_slice(&resampled);
                pending_decode_samples += resampled.len();
                retain_ft2_window(&mut ft2_buf);

                while pending_decode_samples >= FT2_ASYNC_TRIGGER_SAMPLES
                    && ft2_buf.len() >= FT2_ASYNC_BUFFER_SAMPLES
                {
                    pending_decode_samples -= FT2_ASYNC_TRIGGER_SAMPLES;
                    let results = tokio::task::block_in_place(|| {
                        let _span = info_span!("ft2_decode").entered();
                        decode_ft2_window(&mut decoder, &ft2_buf, 100)
                    });
                    for res in results {
                        let abs_freq_hz = base_freq_hz as f64 + res.freq_hz as f64;
                        let msg = Ft8Message {
                            rig_id: None,
                            ts_ms: current_timestamp_ms(),
                            snr_db: res.snr_db,
                            dt_s: res.dt_s,
                            freq_hz: if abs_freq_hz.is_finite() && abs_freq_hz > 0.0 {
                                abs_freq_hz as f32
                            } else {
                                res.freq_hz
                            },
                            message: res.text,
                        };
                        if !should_emit_ft2_decode(&mut recent_decodes, &msg.message, msg.freq_hz) {
                            continue;
                        }
                        let _ = decode_tx.send(DecodedMessage::Ft2(msg));
                    }
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background FT2 decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

async fn run_background_wspr_decoder(
    sample_rate: u32,
    channels: u16,
    mut pcm_rx: broadcast::Receiver<Vec<f32>>,
    base_freq_hz: u64,
    decode_tx: broadcast::Sender<DecodedMessage>,
) {
    info!(
        "Background WSPR decoder started ({}Hz, {} ch @ {} Hz)",
        sample_rate, channels, base_freq_hz
    );
    let decoder = match WsprDecoder::new() {
        Ok(decoder) => decoder,
        Err(err) => {
            warn!("Background WSPR decoder init failed: {}", err);
            return;
        }
    };
    let mut slot_buf: Vec<f32> = Vec::new();
    let mut last_slot: i64 = -1;
    let slot_len_s: i64 = 120;

    loop {
        match pcm_rx.recv().await {
            Ok(frame) => {
                let now = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
                    Ok(dur) => dur.as_secs() as i64,
                    Err(_) => 0,
                };
                let slot = now / slot_len_s;
                if last_slot == -1 {
                    last_slot = slot;
                } else if slot != last_slot {
                    match tokio::task::block_in_place(|| {
                        let _span = info_span!("wspr_decode").entered();
                        decoder.decode_slot(&slot_buf, Some(base_freq_hz))
                    }) {
                        Ok(results) => {
                            for res in results {
                                let msg = WsprMessage {
                                    rig_id: None,
                                    ts_ms: current_timestamp_ms(),
                                    snr_db: res.snr_db,
                                    dt_s: res.dt_s,
                                    freq_hz: res.freq_hz,
                                    message: res.message,
                                };
                                let _ = decode_tx.send(DecodedMessage::Wspr(msg));
                            }
                        }
                        Err(err) => warn!("Background WSPR decode failed: {}", err),
                    }
                    slot_buf.clear();
                    last_slot = slot;
                }

                let mut mono = downmix_mono(frame, channels);
                apply_decode_audio_gate(&mut mono);
                let Some(resampled) = resample_to_12k(&mono, sample_rate) else {
                    warn!(
                        "Background WSPR decoder: unsupported sample rate {}",
                        sample_rate
                    );
                    break;
                };
                slot_buf.extend_from_slice(&resampled);
                if slot_buf.len() > decoder.slot_samples() {
                    let keep = decoder.slot_samples();
                    let drain = slot_buf.len().saturating_sub(keep);
                    if drain > 0 {
                        slot_buf.drain(..drain);
                    }
                }
            }
            Err(broadcast::error::RecvError::Lagged(n)) => {
                warn!("Background WSPR decoder: dropped {} PCM frames", n);
            }
            Err(broadcast::error::RecvError::Closed) => break,
        }
    }
}

/// Run the audio TCP listener, accepting client connections.
#[allow(clippy::too_many_arguments)]
pub async fn run_audio_listener(
    addr: SocketAddr,
    rx_audio: broadcast::Sender<Bytes>,
    tx_audio: mpsc::Sender<Bytes>,
    stream_info: AudioStreamInfo,
    decode_tx: broadcast::Sender<DecodedMessage>,
    mut shutdown_rx: watch::Receiver<bool>,
    histories: Arc<DecoderHistories>,
    vchan_manager: Option<SharedVChanManager>,
) -> std::io::Result<()> {
    let listener = TcpListener::bind(addr).await?;
    info!("Audio listener on {}", addr);

    loop {
        tokio::select! {
            accept = listener.accept() => {
                let (socket, peer) = accept?;
                info!("Audio client connected: {}", peer);

                let rx_audio = rx_audio.clone();
                let tx_audio = tx_audio.clone();
                let info = stream_info.clone();
                let decode_tx = decode_tx.clone();
                let client_shutdown_rx = shutdown_rx.clone();
                let client_histories = histories.clone();
                let client_vchan_mgr = vchan_manager.clone();

                tokio::spawn(async move {
                    if let Err(e) = handle_audio_client(socket, peer, rx_audio, tx_audio, info, decode_tx, client_shutdown_rx, client_histories, client_vchan_mgr).await {
                        warn!("Audio client {} error: {:?}", peer, e);
                    }
                    info!("Audio client {} disconnected", peer);
                });
            }
            changed = shutdown_rx.changed() => {
                match changed {
                    Ok(()) if *shutdown_rx.borrow() => {
                        info!("Audio listener shutting down");
                        break;
                    }
                    Ok(()) => {}
                    Err(_) => break,
                }
            }
        }
    }
    Ok(())
}

/// Returns the next destroyed-channel UUID, or `pending()` when the receiver
/// has been closed or is not present.  Disables itself on close so the
/// enclosing `select!` never busy-loops on a dead channel.
async fn recv_destroyed(rx: &mut Option<broadcast::Receiver<Uuid>>) -> Option<Uuid> {
    match rx {
        None => std::future::pending::<Option<Uuid>>().await,
        Some(r) => match r.recv().await {
            Ok(uuid) => Some(uuid),
            Err(broadcast::error::RecvError::Lagged(_)) => None,
            Err(broadcast::error::RecvError::Closed) => {
                *rx = None;
                std::future::pending::<Option<Uuid>>().await
            }
        },
    }
}

#[allow(clippy::too_many_arguments)]
async fn handle_audio_client(
    socket: TcpStream,
    peer: SocketAddr,
    rx_audio: broadcast::Sender<Bytes>,
    tx_audio: mpsc::Sender<Bytes>,
    stream_info: AudioStreamInfo,
    decode_tx: broadcast::Sender<DecodedMessage>,
    mut shutdown_rx: watch::Receiver<bool>,
    histories: Arc<DecoderHistories>,
    vchan_manager: Option<SharedVChanManager>,
) -> std::io::Result<()> {
    let (reader, writer) = socket.into_split();
    let mut reader = tokio::io::BufReader::new(reader);
    let mut writer = tokio::io::BufWriter::new(writer);

    // Send stream info
    let info_json = serde_json::to_vec(&stream_info).map_err(std::io::Error::other)?;
    write_audio_msg(&mut writer, AUDIO_MSG_STREAM_INFO, &info_json).await?;

    let history_replay_started_at = Instant::now();

    // Build one in-memory blob of all history records, then split it into
    // chunks at message boundaries before gzipping. A single oversized blob
    // can exceed the 16 MiB MAX_HISTORY_PAYLOAD_SIZE the client enforces;
    // chunking keeps each frame well under the limit while still amortising
    // the gzip cost across many records per chunk.
    let (blob, replayed_history_count) = build_history_blob(&histories);
    if !blob.is_empty() {
        // Target uncompressed bytes per chunk. JSON compresses ~10-20x so
        // 8 MiB uncompressed lands well under the 16 MiB compressed cap even
        // in adversarial cases (image-heavy LRPT/WEFAX history, low ratio).
        const CHUNK_THRESHOLD_BYTES: usize = 8 * 1024 * 1024;

        let mut total_compressed = 0usize;
        let mut chunks_sent = 0usize;
        for range in split_history_chunks(&blob, CHUNK_THRESHOLD_BYTES) {
            let slice = &blob[range];
            let mut enc = GzEncoder::new(Vec::with_capacity(slice.len() / 8), Compression::fast());
            let compressed = enc
                .write_all(slice)
                .and_then(|_| enc.finish())
                .unwrap_or_else(|_| slice.to_vec());
            write_audio_msg(&mut writer, AUDIO_MSG_HISTORY_COMPRESSED, &compressed).await?;
            total_compressed += compressed.len();
            chunks_sent += 1;
        }

        info!(
            "Audio client {} replayed {} history messages in {} chunk(s) in {:?} ({} → {} bytes, {:.1}x)",
            peer,
            replayed_history_count,
            chunks_sent,
            history_replay_started_at.elapsed(),
            blob.len(),
            total_compressed,
            blob.len() as f64 / total_compressed.max(1) as f64,
        );
    }

    // Spawn RX + decode forwarding task (shares the writer).
    // vchan audio frames are fed into this task via vchan_frame_rx so all
    // writes share one BufWriter and stay ordered.
    let mut rx_sub = rx_audio.subscribe();
    let mut decode_sub = decode_tx.subscribe();
    let mut writer_for_rx = writer;

    // (uuid, opus_bytes) produced by per-channel encoder tasks.
    let (vchan_frame_tx, mut vchan_frame_rx) = mpsc::channel::<(Uuid, Bytes)>(256);
    // Commands from the reader loop: Subscribe / Unsubscribe.
    let (vchan_cmd_tx, mut vchan_cmd_rx) = mpsc::channel::<VChanCmd>(32);
    let (bg_decode_tx, mut bg_decode_rx) = broadcast::channel::<DecodedMessage>(128);

    let opus_sample_rate = stream_info.sample_rate;
    let opus_channels = stream_info.channels;

    // Subscribe to server-side channel destruction events (SDR rigs only).
    let mut destroyed_rx: Option<broadcast::Receiver<Uuid>> =
        vchan_manager.as_ref().map(|m| m.subscribe_destroyed());

    let hidden_channels = Arc::new(Mutex::new(HashSet::<Uuid>::new()));
    let hidden_channels_for_rx = hidden_channels.clone();
    let vchan_manager_for_rx = vchan_manager.clone();
    let rx_handle = tokio::spawn(async move {
        // UUID → JoinHandles of per-channel encoder/decoder tasks.
        let mut vchan_tasks: std::collections::HashMap<Uuid, Vec<tokio::task::JoinHandle<()>>> =
            std::collections::HashMap::new();

        loop {
            tokio::select! {
                result = rx_sub.recv() => {
                    match result {
                        Ok(packet) => {
                            if let Err(e) = write_audio_msg(&mut writer_for_rx, AUDIO_MSG_RX_FRAME, &packet).await {
                                warn!("Audio RX write to {} failed: {}", peer, e);
                                break;
                            }
                        }
                        Err(broadcast::error::RecvError::Lagged(n)) => {
                            warn!("Audio RX: {} dropped {} frames", peer, n);
                        }
                        Err(broadcast::error::RecvError::Closed) => break,
                    }
                }
                result = decode_sub.recv() => {
                    match result {
                        Ok(msg) => {
                            let msg_type = match &msg {
                                DecodedMessage::Ais(_) => AUDIO_MSG_AIS_DECODE,
                                DecodedMessage::Vdes(_) => AUDIO_MSG_VDES_DECODE,
                                DecodedMessage::Aprs(_) => AUDIO_MSG_APRS_DECODE,
                                DecodedMessage::HfAprs(_) => AUDIO_MSG_HF_APRS_DECODE,
                                DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
                                DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
                                DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
                                DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
                                DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,

                                DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
                                DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
                                DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
                                DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
                            };
                            if let Ok(json) = serde_json::to_vec(&msg) {
                                if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
                                    warn!("Audio decode write to {} failed: {}", peer, e);
                                    break;
                                }
                            }
                        }
                        Err(broadcast::error::RecvError::Lagged(n)) => {
                            warn!("Audio decode: {} dropped {} messages", peer, n);
                        }
                        Err(broadcast::error::RecvError::Closed) => break,
                    }
                }
                result = bg_decode_rx.recv() => {
                    match result {
                        Ok(msg) => {
                            let msg_type = match &msg {
                                DecodedMessage::Ais(_) => AUDIO_MSG_AIS_DECODE,
                                DecodedMessage::Vdes(_) => AUDIO_MSG_VDES_DECODE,
                                DecodedMessage::Aprs(_) => AUDIO_MSG_APRS_DECODE,
                                DecodedMessage::HfAprs(_) => AUDIO_MSG_HF_APRS_DECODE,
                                DecodedMessage::Cw(_) => AUDIO_MSG_CW_DECODE,
                                DecodedMessage::Ft8(_) => AUDIO_MSG_FT8_DECODE,
                                DecodedMessage::Ft4(_) => AUDIO_MSG_FT4_DECODE,
                                DecodedMessage::Ft2(_) => AUDIO_MSG_FT2_DECODE,
                                DecodedMessage::Wspr(_) => AUDIO_MSG_WSPR_DECODE,

                                DecodedMessage::LrptImage(_) => AUDIO_MSG_LRPT_IMAGE,
                                DecodedMessage::LrptProgress(_) => AUDIO_MSG_LRPT_PROGRESS,
                                DecodedMessage::Wefax(_) => AUDIO_MSG_WEFAX_DECODE,
                                DecodedMessage::WefaxProgress(_) => AUDIO_MSG_WEFAX_PROGRESS,
                            };
                            if let Ok(json) = serde_json::to_vec(&msg) {
                                if let Err(e) = write_audio_msg(&mut writer_for_rx, msg_type, &json).await {
                                    warn!("Audio background decode write to {} failed: {}", peer, e);
                                    break;
                                }
                            }
                        }
                        Err(broadcast::error::RecvError::Lagged(n)) => {
                            warn!("Audio background decode: {} dropped {} messages", peer, n);
                        }
                        Err(broadcast::error::RecvError::Closed) => break,
                    }
                }
                // Virtual-channel audio frame produced by a per-channel encoder task.
                Some((uuid, opus)) = vchan_frame_rx.recv() => {
                    if let Err(e) = write_vchan_audio_frame(&mut writer_for_rx, uuid, opus.as_ref()).await {
                        warn!("Audio vchan RX_FRAME_CH write to {} failed: {}", peer, e);
                        break;
                    }
                }
                // Commands from reader loop: subscribe / unsubscribe.
                Some(cmd) = vchan_cmd_rx.recv() => {
                    match cmd {
                        VChanCmd::Subscribe { uuid, pcm_rx, send_audio, background_decode } => {
                            let mut handles = Vec::new();
                            let is_hidden = !send_audio;

                            if let Ok(mut guard) = hidden_channels_for_rx.lock() {
                                if is_hidden {
                                    guard.insert(uuid);
                                } else {
                                    guard.remove(&uuid);
                                }
                            }

                            if let Some(existing_handles) = vchan_tasks.remove(&uuid) {
                                for handle in existing_handles {
                                    handle.abort();
                                }
                            }

                            if send_audio {
                                // Spin up an async Opus encoder task for this virtual channel.
                                let frame_tx = vchan_frame_tx.clone();
                                let sr       = opus_sample_rate;
                                let ch_count = opus_channels;
                                let mut pcm_rx_audio = pcm_rx.resubscribe();
                                handles.push(tokio::spawn(async move {
                                    let opus_ch = match ch_count {
                                        1 => opus::Channels::Mono,
                                        2 => opus::Channels::Stereo,
                                        _ => return,
                                    };
                                    let mut encoder = match opus::Encoder::new(
                                        sr,
                                        opus_ch,
                                        opus::Application::Audio,
                                    ) {
                                        Ok(e) => e,
                                        Err(e) => {
                                            warn!("vchan Opus encoder init failed: {}", e);
                                            return;
                                        }
                                    };
                                    let _ = encoder.set_bitrate(opus::Bitrate::Bits(32_000));
                                    let _ = encoder.set_complexity(5);
                                    let mut buf = vec![0u8; 4096];
                                    loop {
                                        match pcm_rx_audio.recv().await {
                                            Ok(frame) => {
                                                match encoder.encode_float(&frame, &mut buf) {
                                                    Ok(len) => {
                                                        let pkt = Bytes::copy_from_slice(&buf[..len]);
                                                        if frame_tx.send((uuid, pkt)).await.is_err() {
                                                            break;
                                                        }
                                                    }
                                                    Err(e) => {
                                                        warn!("vchan Opus encode error: {}", e);
                                                    }
                                                }
                                            }
                                            Err(broadcast::error::RecvError::Lagged(n)) => {
                                                warn!("vchan encoder: dropped {} PCM frames", n);
                                            }
                                            Err(broadcast::error::RecvError::Closed) => break,
                                        }
                                    }
                                }));
                            }

                            if let Some(spec) = background_decode {
                                let base_freq_hz = spec.base_freq_hz;
                                for kind in spec.decoder_kinds {
                                    let decode_tx = bg_decode_tx.clone();
                                    let task_rx = pcm_rx.resubscribe();
                                    let sr = opus_sample_rate;
                                    let ch_count = opus_channels as u16;
                                    let kind = kind.to_ascii_lowercase();
                                    let handle = match kind.as_str() {
                                        "aprs" => tokio::spawn(async move {
                                            run_background_aprs_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        "ais" => tokio::spawn(async move {
                                            run_background_ais_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        "ft8" => tokio::spawn(async move {
                                            run_background_ft8_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                base_freq_hz,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        "ft4" => tokio::spawn(async move {
                                            run_background_ft4_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                base_freq_hz,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        #[cfg(feature = "ft2")]
                                        "ft2" => tokio::spawn(async move {
                                            run_background_ft2_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                base_freq_hz,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        "wspr" => tokio::spawn(async move {
                                            run_background_wspr_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                base_freq_hz,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        "hf-aprs" => tokio::spawn(async move {
                                            run_background_hf_aprs_decoder(
                                                sr,
                                                ch_count,
                                                task_rx,
                                                decode_tx,
                                            )
                                            .await;
                                        }),
                                        other => {
                                            warn!("Unsupported background decoder kind '{}'", other);
                                            continue;
                                        }
                                    };
                                    handles.push(handle);
                                }
                            }

                            if !handles.is_empty() {
                                vchan_tasks.insert(uuid, handles);
                            }

                            // Acknowledge to the client.
                            if let Err(e) = write_vchan_uuid_msg(
                                &mut writer_for_rx,
                                AUDIO_MSG_VCHAN_ALLOCATED,
                                uuid,
                            )
                            .await
                            {
                                warn!("Audio vchan allocated write to {} failed: {}", peer, e);
                                break;
                            }
                        }
                        VChanCmd::Unsubscribe(uuid) => {
                            if let Some(handles) = vchan_tasks.remove(&uuid) {
                                for handle in handles {
                                    handle.abort();
                                }
                            }
                            if let Ok(mut guard) = hidden_channels_for_rx.lock() {
                                guard.remove(&uuid);
                            }
                        }
                    }
                }
                uuid = recv_destroyed(&mut destroyed_rx) => {
                    if let Some(uuid) = uuid {
                        // Stop encoding for this channel.
                        if let Some(handles) = vchan_tasks.remove(&uuid) {
                            for handle in handles {
                                handle.abort();
                            }
                        }
                        if let Ok(mut guard) = hidden_channels_for_rx.lock() {
                            guard.remove(&uuid);
                        }
                        // Notify the client.
                        if let Err(e) = write_vchan_uuid_msg(
                            &mut writer_for_rx,
                            AUDIO_MSG_VCHAN_DESTROYED,
                            uuid,
                        )
                        .await
                        {
                            warn!("Audio vchan destroyed write to {} failed: {}", peer, e);
                            break;
                        }
                    }
                }
            }
        }

        // Abort all per-channel encoder tasks on disconnect.
        for (_, handles) in vchan_tasks {
            for handle in handles {
                handle.abort();
            }
        }
        let stale_hidden_channels: Vec<Uuid> = hidden_channels_for_rx
            .lock()
            .map(|guard| guard.iter().copied().collect())
            .unwrap_or_default();
        if let Some(ref mgr) = vchan_manager_for_rx {
            for uuid in &stale_hidden_channels {
                let _ = mgr.remove_channel(*uuid);
            }
        }
        if !stale_hidden_channels.is_empty() {
            info!(
                "Audio client {} cleaned up {} hidden background channels",
                peer,
                stale_hidden_channels.len()
            );
        }
    });

    // Read TX frames (and virtual-channel sub/unsub commands) from client.
    loop {
        let msg = tokio::select! {
            msg = read_audio_msg(&mut reader) => msg,
            changed = shutdown_rx.changed() => {
                match changed {
                    Ok(()) if *shutdown_rx.borrow() => {
                        rx_handle.abort();
                        return Ok(());
                    }
                    Ok(()) => continue,
                    Err(_) => {
                        rx_handle.abort();
                        return Ok(());
                    }
                }
            }
        };
        match msg {
            Ok((AUDIO_MSG_TX_FRAME, payload)) => {
                let _ = tx_audio.send(Bytes::from(payload)).await;
            }
            Ok((AUDIO_MSG_VCHAN_SUB, payload)) => {
                if let Some(ref mgr) = vchan_manager {
                    // Payload: JSON { "uuid": "...", "freq_hz": N, "mode": "..." }
                    match serde_json::from_slice::<serde_json::Value>(&payload) {
                        Ok(v) => {
                            let uuid = v["uuid"].as_str().and_then(|s| s.parse::<Uuid>().ok());
                            let freq_hz = v["freq_hz"].as_u64();
                            let mode_str = v["mode"].as_str().unwrap_or("USB");
                            let hidden = v["hidden"].as_bool().unwrap_or(false);
                            let bandwidth_hz = v["bandwidth_hz"].as_u64().map(|bw| bw as u32);
                            let decoder_kinds = v["decoder_kinds"]
                                .as_array()
                                .map(|arr| {
                                    arr.iter()
                                        .filter_map(|item| item.as_str().map(str::to_string))
                                        .collect::<Vec<_>>()
                                })
                                .unwrap_or_default();
                            let mode = trx_protocol::codec::parse_mode(mode_str);
                            if let (Some(uuid), Some(freq_hz)) = (uuid, freq_hz) {
                                let ensure_result = if hidden {
                                    mgr.ensure_background_channel_pcm(uuid, freq_hz, &mode)
                                } else {
                                    mgr.ensure_channel_pcm(uuid, freq_hz, &mode)
                                };
                                match ensure_result {
                                    Ok(pcm_rx) => {
                                        if let Some(bandwidth_hz) =
                                            bandwidth_hz.filter(|bw| *bw > 0)
                                        {
                                            if let Err(e) =
                                                mgr.set_channel_bandwidth(uuid, bandwidth_hz)
                                            {
                                                warn!(
                                                    "Audio vchan SUB bandwidth apply failed: {}",
                                                    e
                                                );
                                            }
                                        }
                                        let _ = vchan_cmd_tx
                                            .send(VChanCmd::Subscribe {
                                                uuid,
                                                pcm_rx,
                                                send_audio: !hidden,
                                                background_decode: (!decoder_kinds.is_empty())
                                                    .then_some(BackgroundDecodeSpec {
                                                        base_freq_hz: freq_hz,
                                                        decoder_kinds,
                                                    }),
                                            })
                                            .await;
                                    }
                                    Err(e) => warn!("Audio vchan SUB: {}", e),
                                }
                            } else {
                                warn!("Audio vchan SUB: missing uuid or freq_hz in payload");
                            }
                        }
                        Err(e) => warn!("Audio vchan SUB: bad JSON payload: {}", e),
                    }
                }
            }
            Ok((AUDIO_MSG_VCHAN_UNSUB, payload)) => match parse_vchan_uuid_msg(&payload) {
                Ok(uuid) => {
                    let _ = vchan_cmd_tx.send(VChanCmd::Unsubscribe(uuid)).await;
                }
                Err(e) => warn!("Audio vchan UNSUB: bad payload: {}", e),
            },
            Ok((AUDIO_MSG_VCHAN_FREQ, payload)) => {
                if let Some(ref mgr) = vchan_manager {
                    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&payload) {
                        if let (Some(uuid), Some(freq_hz)) = (
                            v["uuid"].as_str().and_then(|s| s.parse::<Uuid>().ok()),
                            v["freq_hz"].as_u64(),
                        ) {
                            if let Err(e) = mgr.set_channel_freq(uuid, freq_hz) {
                                warn!("Audio vchan FREQ: {}", e);
                            }
                        }
                    }
                }
            }
            Ok((AUDIO_MSG_VCHAN_MODE, payload)) => {
                if let Some(ref mgr) = vchan_manager {
                    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&payload) {
                        if let Some(uuid) = v["uuid"].as_str().and_then(|s| s.parse::<Uuid>().ok())
                        {
                            let mode = trx_protocol::codec::parse_mode(
                                v["mode"].as_str().unwrap_or("USB"),
                            );
                            if let Err(e) = mgr.set_channel_mode(uuid, &mode) {
                                warn!("Audio vchan MODE: {}", e);
                            }
                        }
                    }
                }
            }
            Ok((AUDIO_MSG_VCHAN_BW, payload)) => {
                if let Some(ref mgr) = vchan_manager {
                    if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&payload) {
                        if let (Some(uuid), Some(bandwidth_hz)) = (
                            v["uuid"].as_str().and_then(|s| s.parse::<Uuid>().ok()),
                            v["bandwidth_hz"].as_u64().map(|b| b as u32),
                        ) {
                            if let Err(e) = mgr.set_channel_bandwidth(uuid, bandwidth_hz) {
                                warn!("Audio vchan BW: {}", e);
                            }
                        }
                    }
                }
            }
            Ok((AUDIO_MSG_VCHAN_REMOVE, payload)) => {
                match parse_vchan_uuid_msg(&payload) {
                    Ok(uuid) => {
                        // Unsubscribe first.
                        let _ = vchan_cmd_tx.send(VChanCmd::Unsubscribe(uuid)).await;
                        if let Ok(mut guard) = hidden_channels.lock() {
                            guard.remove(&uuid);
                        }
                        // Then remove from the DSP pipeline.
                        if let Some(ref mgr) = vchan_manager {
                            if let Err(e) = mgr.remove_channel(uuid) {
                                warn!("Audio vchan REMOVE: {}", e);
                            }
                        }
                    }
                    Err(e) => warn!("Audio vchan REMOVE: bad payload: {}", e),
                }
            }
            Ok((msg_type, _)) => {
                warn!(
                    "Audio: unexpected message type {:#04x} from {}",
                    msg_type, peer
                );
            }
            Err(_) => break,
        }
    }

    rx_handle.abort();
    Ok(())
}

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

    fn frame(msg_type: u8, payload: &[u8]) -> Vec<u8> {
        let mut out = Vec::with_capacity(5 + payload.len());
        out.push(msg_type);
        out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
        out.extend_from_slice(payload);
        out
    }

    #[test]
    fn split_history_chunks_empty_blob_yields_no_chunks() {
        assert!(split_history_chunks(&[], 1024).is_empty());
    }

    #[test]
    fn split_history_chunks_single_record_under_threshold_one_chunk() {
        let blob = frame(AUDIO_MSG_FT8_DECODE, &[0u8; 100]);
        let chunks = split_history_chunks(&blob, 1024);
        assert_eq!(chunks, vec![0..blob.len()]);
    }

    #[test]
    fn split_history_chunks_multiple_records_one_chunk_when_under() {
        let mut blob = Vec::new();
        blob.extend(frame(AUDIO_MSG_FT8_DECODE, &[0u8; 100]));
        blob.extend(frame(AUDIO_MSG_FT4_DECODE, &[0u8; 100]));
        blob.extend(frame(AUDIO_MSG_AIS_DECODE, &[0u8; 100]));
        let chunks = split_history_chunks(&blob, 1024);
        assert_eq!(chunks, vec![0..blob.len()]);
    }

    #[test]
    fn split_history_chunks_splits_at_record_boundaries() {
        // Three 100-byte payloads (105 bytes per record). Threshold of 200
        // means: record 1 fits alone; adding record 2 stays at 210 (over) so
        // chunk 1 contains only record 1; record 2 starts chunk 2; record 3
        // pushes chunk 2 to 210 (over) so chunk 2 contains only record 2; etc.
        let r1 = frame(AUDIO_MSG_FT8_DECODE, &[0u8; 100]);
        let r2 = frame(AUDIO_MSG_FT4_DECODE, &[0u8; 100]);
        let r3 = frame(AUDIO_MSG_AIS_DECODE, &[0u8; 100]);
        let mut blob = Vec::new();
        blob.extend_from_slice(&r1);
        blob.extend_from_slice(&r2);
        blob.extend_from_slice(&r3);
        let chunks = split_history_chunks(&blob, 200);
        assert_eq!(
            chunks,
            vec![
                0..r1.len(),
                r1.len()..r1.len() + r2.len(),
                r1.len() + r2.len()..blob.len(),
            ]
        );
    }

    #[test]
    fn split_history_chunks_oversize_record_alone() {
        // Record exceeds threshold; it still appears as its own chunk because
        // we never split a single record across frames.
        let blob = frame(AUDIO_MSG_LRPT_IMAGE, &[0u8; 4096]);
        let chunks = split_history_chunks(&blob, 1024);
        assert_eq!(chunks, vec![0..blob.len()]);
    }

    #[test]
    fn split_history_chunks_drops_truncated_header() {
        // Tail has only 3 bytes of a 5-byte header — must be dropped.
        let mut blob = frame(AUDIO_MSG_FT8_DECODE, &[1u8; 50]);
        let valid_end = blob.len();
        blob.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
        let chunks = split_history_chunks(&blob, 1024);
        assert_eq!(chunks, vec![0..valid_end]);
    }

    #[test]
    fn split_history_chunks_drops_truncated_payload() {
        // Header claims 200 bytes but only 50 follow — drop it.
        let mut blob = frame(AUDIO_MSG_FT8_DECODE, &[1u8; 50]);
        let valid_end = blob.len();
        blob.push(AUDIO_MSG_AIS_DECODE);
        blob.extend_from_slice(&200u32.to_be_bytes());
        blob.extend_from_slice(&[0u8; 50]);
        let chunks = split_history_chunks(&blob, 1024);
        assert_eq!(chunks, vec![0..valid_end]);
    }

    #[test]
    fn split_history_chunks_threshold_exact_keeps_record_in_chunk() {
        // Two records of exactly 100 bytes each (total 105 framed). Threshold
        // == record-end => `>` is false, so the second record stays in chunk 1.
        let r1 = frame(AUDIO_MSG_FT8_DECODE, &[0u8; 95]);
        assert_eq!(r1.len(), 100);
        let mut blob = Vec::new();
        blob.extend_from_slice(&r1);
        blob.extend_from_slice(&r1);
        let chunks = split_history_chunks(&blob, 200);
        assert_eq!(chunks, vec![0..200]);
    }

    #[test]
    fn classify_stream_error_categorises_known_strings() {
        assert_eq!(
            classify_stream_error("snd_pcm_poll_descriptors revents error"),
            "alsa_poll_failure"
        );
        assert_eq!(
            classify_stream_error("alsa::poll() returned POLLERR"),
            "alsa_poll_failure"
        );
        assert_eq!(
            classify_stream_error("Input stream closed"),
            "input_stream_error"
        );
        assert_eq!(
            classify_stream_error("Output stream xrun"),
            "output_stream_error"
        );
        assert_eq!(
            classify_stream_error("device disappeared"),
            "other_stream_error"
        );
        assert_eq!(classify_stream_error(""), "other_stream_error");
    }

    #[test]
    fn enforce_capacity_keeps_newest_when_over() {
        let mut q: VecDeque<i32> = (0..20).collect();
        enforce_capacity(&mut q, 5);
        assert_eq!(q.len(), 5);
        assert_eq!(q.front().copied(), Some(15));
        assert_eq!(q.back().copied(), Some(19));
    }

    #[test]
    fn enforce_capacity_no_op_when_under_or_equal() {
        let mut q: VecDeque<i32> = (0..3).collect();
        enforce_capacity(&mut q, 5);
        assert_eq!(q.len(), 3);
        let mut q: VecDeque<i32> = (0..5).collect();
        enforce_capacity(&mut q, 5);
        assert_eq!(q.len(), 5);
    }

    #[test]
    fn enforce_capacity_handles_zero_max() {
        let mut q: VecDeque<i32> = (0..5).collect();
        enforce_capacity(&mut q, 0);
        assert!(q.is_empty());
    }

    #[test]
    fn downmix_if_needed_passthrough_for_mono() {
        let v = vec![1.0, 2.0, 3.0];
        assert_eq!(downmix_if_needed(v.clone(), 1), v);
        // channels == 0 also passes through (defensive)
        assert_eq!(downmix_if_needed(v.clone(), 0), v);
    }

    #[test]
    fn downmix_if_needed_takes_first_channel_for_stereo() {
        // Interleaved L/R: result keeps L only.
        let v = vec![1.0, 9.0, 2.0, 9.0, 3.0, 9.0];
        assert_eq!(downmix_if_needed(v, 2), vec![1.0, 2.0, 3.0]);
    }

    #[test]
    fn downmix_mono_matches_downmix_if_needed() {
        let v = vec![1.0, 9.0, 2.0, 9.0, 3.0, 9.0];
        assert_eq!(downmix_mono(v.clone(), 2), downmix_if_needed(v, 2));
    }

    #[test]
    fn frame_rms_zero_for_empty_or_silent() {
        assert_eq!(frame_rms(&[]), 0.0);
        assert_eq!(frame_rms(&[0.0; 16]), 0.0);
    }

    #[test]
    fn frame_rms_dc_signal_equals_amplitude() {
        let r = frame_rms(&[0.5; 32]);
        assert!((r - 0.5).abs() < 1e-6, "rms={}", r);
    }

    #[test]
    fn frame_rms_sine_equals_amp_over_sqrt2() {
        // 1024-sample full-cycle sine at amplitude 1.0 — expected RMS ≈ 0.707
        let n = 1024;
        let samples: Vec<f32> = (0..n)
            .map(|i| (i as f32 * std::f32::consts::TAU / n as f32).sin())
            .collect();
        let r = frame_rms(&samples);
        assert!(
            (r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3,
            "rms={}",
            r
        );
    }

    #[test]
    fn apply_decode_audio_gate_passes_loud_audio() {
        let mut s = vec![0.5; 64];
        let muted = apply_decode_audio_gate(&mut s);
        assert!(!muted);
        assert_eq!(s, vec![0.5; 64]);
    }

    #[test]
    fn apply_decode_audio_gate_mutes_quiet_audio() {
        let mut s = vec![1e-6; 64];
        let muted = apply_decode_audio_gate(&mut s);
        assert!(muted);
        assert!(s.iter().all(|&x| x == 0.0));
    }

    #[test]
    fn resample_to_12k_passthrough_when_already_12k() {
        let s = vec![1.0, 2.0, 3.0];
        assert_eq!(resample_to_12k(&s, FT8_SAMPLE_RATE), Some(s));
    }

    #[test]
    fn resample_to_12k_decimates_48k_to_12k_with_averaging() {
        // 4:1 decimation should average each group of 4 samples.
        let input = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
        let out = resample_to_12k(&input, 48_000).expect("supported rate");
        assert_eq!(out, vec![2.5, 6.5]);
    }

    #[test]
    fn resample_to_12k_rejects_non_multiple_rate() {
        // 11_025 Hz is not a multiple of 12_000.
        assert!(resample_to_12k(&[0.0; 1024], 11_025).is_none());
    }

    #[test]
    fn opus_channels_maps_supported_counts() {
        assert!(matches!(opus_channels(1), Ok(opus::Channels::Mono)));
        assert!(matches!(opus_channels(2), Ok(opus::Channels::Stereo)));
        assert!(opus_channels(0).is_err());
        assert!(opus_channels(3).is_err());
    }

    #[cfg(feature = "ft2")]
    #[test]
    fn retain_ft2_window_drops_oldest_excess() {
        let mut buf: Vec<f32> = (0..(FT2_ASYNC_BUFFER_SAMPLES + 100) as i32)
            .map(|i| i as f32)
            .collect();
        retain_ft2_window(&mut buf);
        assert_eq!(buf.len(), FT2_ASYNC_BUFFER_SAMPLES);
        // Oldest 100 should have been drained.
        assert!((buf[0] - 100.0).abs() < f32::EPSILON);
    }

    #[cfg(feature = "ft2")]
    #[test]
    fn retain_ft2_window_no_op_when_within_size() {
        let mut buf: Vec<f32> = vec![1.0; FT2_ASYNC_BUFFER_SAMPLES];
        retain_ft2_window(&mut buf);
        assert_eq!(buf.len(), FT2_ASYNC_BUFFER_SAMPLES);
    }

    #[cfg(feature = "ft2")]
    #[test]
    fn should_emit_ft2_decode_dedupes_and_freq_round() {
        let mut recent = HashMap::new();
        assert!(should_emit_ft2_decode(&mut recent, "CQ DX", 14_074_000.4));
        // Same text + same rounded freq => suppressed.
        assert!(!should_emit_ft2_decode(&mut recent, "CQ DX", 14_074_000.4));
        // Different freq (rounded) => emitted.
        assert!(should_emit_ft2_decode(&mut recent, "CQ DX", 14_074_001.4));
        // Different text => emitted.
        assert!(should_emit_ft2_decode(&mut recent, "CQ ZZ", 14_074_000.4));
    }

    // -------------------------------------------------------------------
    // prune_by_age (generic)
    // -------------------------------------------------------------------

    #[test]
    fn prune_by_age_no_op_on_empty_deque() {
        let mut q: VecDeque<(Instant, ())> = VecDeque::new();
        prune_by_age(&mut q, Duration::from_secs(60), Instant::now());
        assert!(q.is_empty());
    }

    #[test]
    fn prune_by_age_keeps_fresh_entries() {
        let now = Instant::now();
        let mut q: VecDeque<(Instant, u32)> = VecDeque::new();
        q.push_back((now - Duration::from_secs(1), 1));
        q.push_back((now - Duration::from_secs(2), 2));
        prune_by_age(&mut q, Duration::from_secs(60), now);
        assert_eq!(q.len(), 2);
    }

    #[test]
    fn prune_by_age_drops_old_front_only() {
        let now = Instant::now();
        let retention = Duration::from_secs(60);
        let mut q: VecDeque<(Instant, u32)> = VecDeque::new();
        // Two stale entries followed by two fresh ones, in chronological order.
        q.push_back((now - Duration::from_secs(120), 1));
        q.push_back((now - Duration::from_secs(90), 2));
        q.push_back((now - Duration::from_secs(30), 3));
        q.push_back((now - Duration::from_secs(5), 4));
        prune_by_age(&mut q, retention, now);
        let kept: Vec<u32> = q.iter().map(|(_, v)| *v).collect();
        assert_eq!(kept, vec![3, 4]);
    }

    #[test]
    fn prune_by_age_drops_all_when_all_stale() {
        let now = Instant::now();
        let mut q: VecDeque<(Instant, u32)> = VecDeque::new();
        q.push_back((now - Duration::from_secs(3600), 1));
        q.push_back((now - Duration::from_secs(3000), 2));
        prune_by_age(&mut q, Duration::from_secs(60), now);
        assert!(q.is_empty());
    }

    #[test]
    fn prune_by_age_stops_at_first_fresh_entry() {
        // Out-of-order timestamps: a fresh entry in front blocks pruning of
        // older entries behind it. Documents the front-only behaviour.
        let now = Instant::now();
        let retention = Duration::from_secs(60);
        let mut q: VecDeque<(Instant, u32)> = VecDeque::new();
        q.push_back((now - Duration::from_secs(5), 1));
        q.push_back((now - Duration::from_secs(120), 2));
        prune_by_age(&mut q, retention, now);
        let kept: Vec<u32> = q.iter().map(|(_, v)| *v).collect();
        assert_eq!(kept, vec![1, 2]);
    }

    // -------------------------------------------------------------------
    // DecoderHistories integration
    // -------------------------------------------------------------------

    fn sample_ais(mmsi: u32) -> AisMessage {
        AisMessage {
            rig_id: None,
            ts_ms: None,
            channel: "A".into(),
            message_type: 1,
            repeat: 0,
            mmsi,
            crc_ok: true,
            bit_len: 168,
            raw_bytes: vec![0u8; 21],
            lat: None,
            lon: None,
            sog_knots: None,
            cog_deg: None,
            heading_deg: None,
            nav_status: None,
            vessel_name: None,
            callsign: None,
            destination: None,
        }
    }

    fn sample_aprs(crc_ok: bool) -> AprsPacket {
        AprsPacket {
            rig_id: None,
            ts_ms: None,
            src_call: "N0CALL".into(),
            dest_call: "APRS".into(),
            path: String::new(),
            info: String::new(),
            info_bytes: Vec::new(),
            packet_type: "msg".into(),
            crc_ok,
            lat: None,
            lon: None,
            symbol_table: None,
            symbol_code: None,
        }
    }

    fn sample_cw(text: &str) -> CwEvent {
        CwEvent {
            rig_id: None,
            text: text.into(),
            wpm: 20,
            tone_hz: 700,
            signal_on: true,
        }
    }

    fn sample_ft8(seq: i64) -> Ft8Message {
        Ft8Message {
            rig_id: None,
            ts_ms: seq,
            snr_db: -10.0,
            dt_s: 0.5,
            freq_hz: 1500.0,
            message: format!("CQ {}", seq),
        }
    }

    #[test]
    fn record_ais_then_snapshot_round_trip() {
        let h = DecoderHistories::new();
        h.record_ais_message(sample_ais(1));
        h.record_ais_message(sample_ais(2));
        let snap = h.snapshot_ais_history();
        assert_eq!(snap.len(), 2);
        assert_eq!(snap[0].mmsi, 1);
        assert_eq!(snap[1].mmsi, 2);
    }

    #[test]
    fn record_ais_assigns_ts_ms_when_missing() {
        let h = DecoderHistories::new();
        h.record_ais_message(sample_ais(42));
        let snap = h.snapshot_ais_history();
        assert!(snap[0].ts_ms.is_some());
        assert!(snap[0].ts_ms.unwrap() > 0);
    }

    #[test]
    fn record_ais_preserves_caller_supplied_ts_ms() {
        let h = DecoderHistories::new();
        let mut msg = sample_ais(7);
        msg.ts_ms = Some(1_700_000_000_000);
        h.record_ais_message(msg);
        let snap = h.snapshot_ais_history();
        assert_eq!(snap[0].ts_ms, Some(1_700_000_000_000));
    }

    #[test]
    fn record_aprs_drops_packets_with_bad_crc() {
        let h = DecoderHistories::new();
        h.record_aprs_packet(sample_aprs(false));
        h.record_aprs_packet(sample_aprs(false));
        assert!(h.snapshot_aprs_history().is_empty());
        assert_eq!(h.estimated_total_count(), 0);
    }

    #[test]
    fn record_aprs_keeps_packets_with_good_crc() {
        let h = DecoderHistories::new();
        h.record_aprs_packet(sample_aprs(true));
        assert_eq!(h.snapshot_aprs_history().len(), 1);
        assert_eq!(h.estimated_total_count(), 1);
    }

    #[test]
    fn record_evicts_oldest_when_at_capacity() {
        let h = DecoderHistories::new();
        for i in 0..(MAX_HISTORY_ENTRIES + 5) {
            let mut msg = sample_ais(i as u32);
            msg.ts_ms = Some(i as i64);
            h.record_ais_message(msg);
        }
        let snap = h.snapshot_ais_history();
        assert_eq!(snap.len(), MAX_HISTORY_ENTRIES);
        // Oldest 5 (mmsi 0..5) should have been evicted; surviving front is mmsi 5.
        assert_eq!(snap.first().unwrap().mmsi, 5);
        assert_eq!(snap.last().unwrap().mmsi, (MAX_HISTORY_ENTRIES + 4) as u32);
    }

    #[test]
    fn estimated_total_count_starts_at_zero_and_tracks_records() {
        let h = DecoderHistories::new();
        assert_eq!(h.estimated_total_count(), 0);
        h.record_ais_message(sample_ais(1));
        h.record_aprs_packet(sample_aprs(true));
        h.record_cw_event(sample_cw("CQ"));
        h.record_ft8_message(sample_ft8(1));
        h.record_ft4_message(sample_ft8(2));
        assert_eq!(h.estimated_total_count(), 5);
    }

    #[test]
    fn clear_aprs_history_resets_total_count_by_drained_amount() {
        let h = DecoderHistories::new();
        h.record_aprs_packet(sample_aprs(true));
        h.record_aprs_packet(sample_aprs(true));
        h.record_ais_message(sample_ais(1));
        assert_eq!(h.estimated_total_count(), 3);
        h.clear_aprs_history();
        assert!(h.snapshot_aprs_history().is_empty());
        // Only APRS entries are drained — AIS entry remains.
        assert_eq!(h.estimated_total_count(), 1);
    }

    #[test]
    fn adjust_total_count_saturates_on_underflow() {
        let h = DecoderHistories::new();
        // Counter is currently 0. Asking it to decrement by 100 must clamp to
        // 0 instead of wrapping to usize::MAX (which would later panic when
        // pre-allocating the history replay blob).
        h.adjust_total_count(100, 0);
        assert_eq!(h.estimated_total_count(), 0);
    }

    #[test]
    fn adjust_total_count_increments_then_decrements_correctly() {
        let h = DecoderHistories::new();
        h.adjust_total_count(0, 7);
        assert_eq!(h.estimated_total_count(), 7);
        h.adjust_total_count(7, 4);
        assert_eq!(h.estimated_total_count(), 4);
        h.adjust_total_count(4, 4); // no-op
        assert_eq!(h.estimated_total_count(), 4);
    }

    // -------------------------------------------------------------------
    // StreamErrorLogger
    // -------------------------------------------------------------------

    #[test]
    fn stream_error_logger_first_call_emits_error_with_no_flush() {
        let log = StreamErrorLogger::new("input");
        let action = log.log_at("Input stream closed", Instant::now());
        assert_eq!(
            action,
            LogAction::Error {
                flushed_repeated: None
            }
        );
    }

    #[test]
    fn stream_error_logger_repeated_within_interval_is_suppressed() {
        let log = StreamErrorLogger::new("input");
        let t0 = Instant::now();
        log.log_at("Input stream closed", t0);
        let action = log.log_at("Input stream closed", t0 + Duration::from_secs(1));
        assert_eq!(action, LogAction::Suppressed);
    }

    #[test]
    fn stream_error_logger_emits_recurring_after_interval() {
        let log = StreamErrorLogger::new("input");
        let t0 = Instant::now();
        log.log_at("Input stream closed", t0);
        // Suppress two more, then trigger a recurring summary at the interval.
        log.log_at("Input stream closed", t0 + Duration::from_secs(1));
        log.log_at("Input stream closed", t0 + Duration::from_secs(2));
        let action = log.log_at(
            "Input stream closed",
            t0 + AUDIO_STREAM_ERROR_LOG_INTERVAL + Duration::from_millis(1),
        );
        assert_eq!(action, LogAction::Recurring { repeats: 3 });
    }

    #[test]
    fn stream_error_logger_recurring_resets_suppression_window() {
        // After a Recurring summary, immediate next call must be suppressed
        // (window starts again from the recurring timestamp).
        let log = StreamErrorLogger::new("input");
        let t0 = Instant::now();
        log.log_at("Input stream closed", t0);
        log.log_at("Input stream closed", t0 + Duration::from_secs(1));
        let recurring_at = t0 + AUDIO_STREAM_ERROR_LOG_INTERVAL + Duration::from_millis(1);
        let _ = log.log_at("Input stream closed", recurring_at);
        // 1 ms after the recurring summary => suppressed (counter = 1).
        let action = log.log_at(
            "Input stream closed",
            recurring_at + Duration::from_millis(1),
        );
        assert_eq!(action, LogAction::Suppressed);
    }

    #[test]
    fn stream_error_logger_class_change_flushes_prior_repeats() {
        let log = StreamErrorLogger::new("input");
        let t0 = Instant::now();
        log.log_at("Input stream closed", t0);
        // Suppress two of the same kind.
        log.log_at("Input stream closed", t0 + Duration::from_secs(1));
        log.log_at("Input stream closed", t0 + Duration::from_secs(2));
        // Different error class — should flush the suppressed count and
        // immediately emit a fresh error.
        let action = log.log_at("Output stream xrun", t0 + Duration::from_secs(3));
        assert_eq!(
            action,
            LogAction::Error {
                flushed_repeated: Some(2)
            }
        );
    }

    #[test]
    fn stream_error_logger_class_change_without_prior_suppression_does_not_flush() {
        let log = StreamErrorLogger::new("input");
        let t0 = Instant::now();
        log.log_at("Input stream closed", t0);
        // Different class with no prior suppression — flushed_repeated must be
        // None (no "repeated N times" warn emitted).
        let action = log.log_at("Output stream xrun", t0 + Duration::from_secs(1));
        assert_eq!(
            action,
            LogAction::Error {
                flushed_repeated: None
            }
        );
    }

    #[test]
    fn stream_error_logger_classifies_via_classify_stream_error() {
        // Two different strings that both classify_stream_error maps to
        // "alsa_poll_failure" — second call must be suppressed because the
        // class is unchanged even though the error text differs.
        let log = StreamErrorLogger::new("input");
        let t0 = Instant::now();
        log.log_at("snd_pcm_poll_descriptors error A", t0);
        let action = log.log_at(
            "alsa::poll() returned POLLERR thing B",
            t0 + Duration::from_secs(1),
        );
        assert_eq!(action, LogAction::Suppressed);
    }

    #[test]
    fn estimated_total_count_consistent_under_concurrent_records() {
        use std::thread;
        const THREADS: usize = 8;
        const PER_THREAD: usize = 250;
        let h = DecoderHistories::new();
        let handles: Vec<_> = (0..THREADS)
            .map(|t| {
                let h = h.clone();
                thread::spawn(move || {
                    for i in 0..PER_THREAD {
                        h.record_ais_message(sample_ais((t * PER_THREAD + i) as u32));
                    }
                })
            })
            .collect();
        for handle in handles {
            handle.join().unwrap();
        }
        let snap_len = h.snapshot_ais_history().len();
        let counter = h.estimated_total_count();
        assert_eq!(snap_len, THREADS * PER_THREAD);
        assert_eq!(counter, THREADS * PER_THREAD);
    }

    // -------------------------------------------------------------------
    // build_history_blob (integration with DecoderHistories + framing)
    // -------------------------------------------------------------------

    /// Walk a length-prefixed blob and return `(type, payload)` for every
    /// record. Mirrors the parsing the client does on the wire.
    fn parse_blob_records(blob: &[u8]) -> Vec<(u8, Vec<u8>)> {
        let mut out = Vec::new();
        let mut pos = 0usize;
        while pos + 5 <= blob.len() {
            let msg_type = blob[pos];
            let len =
                u32::from_be_bytes([blob[pos + 1], blob[pos + 2], blob[pos + 3], blob[pos + 4]])
                    as usize;
            pos += 5;
            assert!(pos + len <= blob.len(), "truncated blob");
            out.push((msg_type, blob[pos..pos + len].to_vec()));
            pos += len;
        }
        assert_eq!(pos, blob.len(), "trailing bytes in blob");
        out
    }

    fn gunzip(data: &[u8]) -> Vec<u8> {
        use flate2::read::GzDecoder;
        use std::io::Read;
        let mut decoder = GzDecoder::new(data);
        let mut out = Vec::new();
        decoder.read_to_end(&mut out).expect("gunzip");
        out
    }

    #[test]
    fn build_history_blob_empty_when_no_records() {
        let h = DecoderHistories::new();
        let (blob, count) = build_history_blob(&h);
        assert!(blob.is_empty());
        assert_eq!(count, 0);
    }

    #[test]
    fn build_history_blob_emits_records_with_correct_type_bytes() {
        let h = DecoderHistories::new();
        h.record_ais_message(sample_ais(101));
        h.record_aprs_packet(sample_aprs(true));
        h.record_cw_event(sample_cw("CQ TEST"));
        h.record_ft8_message(sample_ft8(42));
        h.record_ft4_message(sample_ft8(43));

        let (blob, count) = build_history_blob(&h);
        assert_eq!(count, 5);

        let records = parse_blob_records(&blob);
        let types: Vec<u8> = records.iter().map(|(t, _)| *t).collect();
        // Decoder iteration order in build_history_blob is fixed:
        // AIS, VDES, APRS, HF_APRS, FT8, FT4, [FT2], WSPR, CW, LRPT, WEFAX.
        assert_eq!(
            types,
            vec![
                AUDIO_MSG_AIS_DECODE,
                AUDIO_MSG_APRS_DECODE,
                AUDIO_MSG_FT8_DECODE,
                AUDIO_MSG_FT4_DECODE,
                AUDIO_MSG_CW_DECODE,
            ]
        );

        // Each record body is a JSON-serialised DecodedMessage variant.
        for (_, payload) in &records {
            let msg: DecodedMessage = serde_json::from_slice(payload).expect("valid JSON");
            // Smoke-check: every variant deserialises to one of the expected types.
            match msg {
                DecodedMessage::Ais(_)
                | DecodedMessage::Aprs(_)
                | DecodedMessage::Cw(_)
                | DecodedMessage::Ft8(_)
                | DecodedMessage::Ft4(_) => {}
                other => panic!("unexpected variant: {:?}", other),
            }
        }
    }

    #[test]
    fn build_history_blob_chunk_split_then_decompress_recovers_all_records() {
        // End-to-end pipeline: build → split → gzip per chunk → decompress
        // each chunk → reassembly must match the original blob byte-for-byte.
        let h = DecoderHistories::new();
        for i in 0..200 {
            h.record_ft8_message(sample_ft8(i));
        }
        let (blob, count) = build_history_blob(&h);
        assert_eq!(count, 200);

        // Threshold small enough to force multiple chunks.
        let threshold = blob.len() / 4;
        let ranges = split_history_chunks(&blob, threshold);
        assert!(
            ranges.len() >= 2,
            "expected multiple chunks, got {}",
            ranges.len()
        );

        let mut reassembled: Vec<u8> = Vec::new();
        for r in &ranges {
            // Gzip then immediately decompress — proves both directions of
            // the on-the-wire transformation are intact.
            let slice = &blob[r.clone()];
            let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
            enc.write_all(slice).unwrap();
            let compressed = enc.finish().unwrap();
            let decompressed = gunzip(&compressed);
            assert_eq!(decompressed, slice);
            reassembled.extend_from_slice(&decompressed);
        }
        assert_eq!(reassembled, blob);

        // Each compressed chunk must fit under the wire-side history cap.
        for r in &ranges {
            let mut enc = GzEncoder::new(Vec::new(), Compression::fast());
            enc.write_all(&blob[r.clone()]).unwrap();
            let compressed = enc.finish().unwrap();
            assert!(
                (compressed.len() as u32) < 16 * 1024 * 1024,
                "chunk over cap: {} bytes",
                compressed.len()
            );
        }
    }

    #[test]
    fn build_history_blob_skips_decoders_with_no_history() {
        let h = DecoderHistories::new();
        // Only one decoder gets a record.
        h.record_aprs_packet(sample_aprs(true));
        let (blob, count) = build_history_blob(&h);
        assert_eq!(count, 1);
        let records = parse_blob_records(&blob);
        assert_eq!(records.len(), 1);
        assert_eq!(records[0].0, AUDIO_MSG_APRS_DECODE);
    }

    #[test]
    fn build_history_blob_count_matches_estimated_total_count() {
        let h = DecoderHistories::new();
        h.record_ais_message(sample_ais(1));
        h.record_aprs_packet(sample_aprs(true));
        h.record_cw_event(sample_cw("X"));
        let (_, count) = build_history_blob(&h);
        assert_eq!(count, h.estimated_total_count());
    }
}
