//! Multitrack (stem) recording: one synced WAV per peer + your mic, plus an
//! optional convenience mixed track — podcast/streamer-grade source for
//! post-production. See `docs/multitrack-recording-plan.md`.
//!
//! All tracks share one master clock: the playout-mixer cycle. Every cycle,
//! **exactly `FRAME_SAMPLES` samples are appended to every track** — real audio
//! for peers who produced a frame that cycle, silence for those idle — so every
//! track stays sample-aligned by construction. A peer that joins mid-recording
//! has its track pre-padded with silence back to cycle 0, so all stems line up
//! at sample 0 on a timeline.
//!
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
//! networking, no realtime work. The mixer (a non-RT task) drives it.

use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};

use iroh::EndpointId;

use crate::audio::recorder::WavWriter;
use crate::core::jitter::FRAME_SAMPLES;

/// Cap on the silence chunk written at once when pre-padding a late joiner, so a
/// long-running call can't trigger a single multi-hundred-MB allocation.
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
const WRITER_QUEUE_CYCLES: usize = 256;
const DROP_LOG_INTERVAL_CYCLES: u64 = 256;

/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
const MAX_MIC_FIFO: usize = 48_000 / 5;
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;

/// Create a collision-free session directory for a timestamp. The base
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
/// recording is never reopened or overwritten.
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
    let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
    let stem = filename.trim_end_matches(".wav");
    for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
        let name = if attempt == 1 {
            stem.to_string()
        } else {
            format!("{stem}-{attempt}")
        };
        let path = base.join(name);
        match std::fs::create_dir(&path) {
            Ok(()) => return Ok(path),
            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
            Err(e) => return Err(e),
        }
    }
    Err(io::Error::new(
        io::ErrorKind::AlreadyExists,
        "multitrack directory suffixes exhausted",
    ))
}

/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
/// happen — Opus frames are uniform), zero-padded if shorter.
fn fit(frame: &[i16], n: usize) -> Vec<i16> {
    let mut v = Vec::with_capacity(n);
    let take = frame.len().min(n);
    v.extend_from_slice(&frame[..take]);
    v.resize(n, 0);
    v
}

/// A filesystem-safe stem filename: a slug of the (already untrusted-sanitized)
/// display name plus a short id suffix to disambiguate same-named peers, e.g.
/// `alice-3b1f9c2a.wav`. Falls back to `peer` when the name slugs to nothing.
pub fn track_filename(name: &str, id: &EndpointId) -> String {
    let clean = crate::sanitize::sanitize_name(name);
    let mut slug: String = clean
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() {
                c.to_ascii_lowercase()
            } else {
                '-'
            }
        })
        .collect();
    // Collapse runs of '-' and trim them off the ends.
    while slug.contains("--") {
        slug = slug.replace("--", "-");
    }
    let slug = slug.trim_matches('-');
    let slug = if slug.is_empty() { "peer" } else { slug };
    let short: String = id.to_string().chars().take(8).collect();
    format!("{slug}-{short}.wav")
}

#[derive(Default)]
struct PendingCycle {
    new_peers: Vec<NewPeer>,
    peer_frames: HashMap<EndpointId, Vec<i16>>,
    mix_frame: Option<Vec<i16>>,
}

struct NewPeer {
    id: EndpointId,
    filename: String,
}

struct CycleBatch {
    new_peers: Vec<NewPeer>,
    mic_frame: Vec<i16>,
    mix_frame: Option<Vec<i16>>,
    peer_frames: HashMap<EndpointId, Vec<i16>>,
}

trait SampleWriter {
    fn write_samples(&mut self, samples: &[i16]) -> io::Result<()>;
    fn finalize(self) -> io::Result<()>;
}

impl SampleWriter for WavWriter {
    fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
        WavWriter::write_samples(self, samples)
    }

    fn finalize(self) -> io::Result<()> {
        WavWriter::finalize(self)
    }
}

struct WriterState<W> {
    dir: PathBuf,
    frame_samples: usize,
    peers: HashMap<EndpointId, W>,
    mic: W,
    mix: Option<W>,
    cycles_written: u64,
}

impl WriterState<WavWriter> {
    fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
        let mic = WavWriter::new(&dir.join("me.wav"))?;
        let mix = if with_mix {
            Some(WavWriter::new(&dir.join("mix.wav"))?)
        } else {
            None
        };
        Ok(Self {
            dir: dir.to_path_buf(),
            frame_samples,
            peers: HashMap::new(),
            mic,
            mix,
            cycles_written: 0,
        })
    }
}

impl<W: SampleWriter> WriterState<W> {
    fn apply_batch<F>(&mut self, batch: &CycleBatch, mut create_peer: F) -> io::Result<()>
    where
        F: FnMut(&Path) -> io::Result<W>,
    {
        for peer in &batch.new_peers {
            if !self.peers.contains_key(&peer.id) {
                let writer = create_peer(&self.dir.join(&peer.filename))?;
                self.peers.insert(peer.id, writer);
                let pad = self.back_pad_samples()?;
                let writer = self.peers.get_mut(&peer.id).unwrap();
                Self::write_silence(writer, pad)?;
            }
        }

        self.mic.write_samples(&batch.mic_frame)?;
        if let Some(mix) = self.mix.as_mut() {
            if let Some(frame) = batch.mix_frame.as_deref() {
                mix.write_samples(frame)?;
            } else {
                Self::write_silence(mix, self.frame_samples)?;
            }
        }

        let silence = vec![0i16; self.frame_samples];
        for (id, writer) in &mut self.peers {
            let frame = batch
                .peer_frames
                .get(id)
                .map(Vec::as_slice)
                .unwrap_or(&silence);
            writer.write_samples(frame)?;
        }
        self.cycles_written += 1;
        Ok(())
    }

    fn back_pad_samples(&self) -> io::Result<usize> {
        let cycles = usize::try_from(self.cycles_written)
            .map_err(|_| io::Error::other("multitrack recording too long"))?;
        cycles
            .checked_mul(self.frame_samples)
            .ok_or_else(|| io::Error::other("multitrack recording too long"))
    }

    fn write_silence(writer: &mut W, samples: usize) -> io::Result<()> {
        let mut remaining = samples;
        let silence = vec![0i16; remaining.min(SILENCE_CHUNK)];
        while remaining > 0 {
            let n = remaining.min(silence.len());
            writer.write_samples(&silence[..n])?;
            remaining -= n;
        }
        Ok(())
    }

    fn finalize(self) -> io::Result<()> {
        let mut first_finalize_error = None;
        record_first_error(&mut first_finalize_error, self.mic.finalize());
        if let Some(mix) = self.mix {
            record_first_error(&mut first_finalize_error, mix.finalize());
        }
        for writer in self.peers.into_values() {
            record_first_error(&mut first_finalize_error, writer.finalize());
        }
        if let Some(e) = first_finalize_error {
            Err(e)
        } else {
            Ok(())
        }
    }
}

fn record_first_error(slot: &mut Option<io::Error>, result: io::Result<()>) {
    if slot.is_none()
        && let Err(e) = result
    {
        *slot = Some(e);
    }
}

/// Applies whole-cycle batches on the writer thread. Each applied batch appends
/// exactly `frame_samples` to every existing track, and a dropped batch never
/// reaches this loop for any track, so stem lengths stay equal even when the
/// bounded queue applies back-pressure.
fn writer_thread_main(
    mut state: WriterState<WavWriter>,
    batch_rx: mpsc::Receiver<CycleBatch>,
) -> io::Result<()> {
    let mut first_write_error = None;

    for batch in batch_rx {
        if first_write_error.is_none()
            && let Err(e) = state.apply_batch(&batch, WavWriter::new)
        {
            first_write_error = Some(e);
        }
    }

    let finalize_result = state.finalize();
    if let Some(e) = first_write_error {
        Err(e)
    } else {
        finalize_result
    }
}

/// A live multitrack recording: per-peer stems + your mic, plus an optional
/// mixed track, all under one session directory and clocked together.
pub struct MultitrackRecorder {
    dir: PathBuf,
    frame_samples: usize,
    known_peers: HashSet<EndpointId>,
    /// Your mic track. Fed asynchronously from the capture thread via
    /// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
    /// one frame per `end_cycle` so it aligns with the cycle clock.
    mic_fifo: VecDeque<i16>,
    /// Present in "Both" mode (stems + mixed), absent in "stems only".
    with_mix: bool,
    batch_tx: SyncSender<CycleBatch>,
    writer_thread: JoinHandle<io::Result<()>>,
    dropped_cycles: u64,
    pending: PendingCycle,
}

impl MultitrackRecorder {
    /// Create a recording in `dir` (which must already exist). `with_mix` adds
    /// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
    pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
        let writer_state = WriterState::create(dir, frame_samples, with_mix)?;
        let (batch_tx, batch_rx) = mpsc::sync_channel(WRITER_QUEUE_CYCLES);
        let writer_thread = thread::spawn(move || writer_thread_main(writer_state, batch_rx));
        Ok(Self {
            dir: dir.to_path_buf(),
            frame_samples,
            known_peers: HashSet::new(),
            mic_fifo: VecDeque::new(),
            with_mix,
            batch_tx,
            writer_thread,
            dropped_cycles: 0,
            pending: PendingCycle::default(),
        })
    }

    /// The session directory holding all the track files.
    pub fn dir(&self) -> &Path {
        &self.dir
    }

    /// Register a peer's stem track, pre-padding it with silence back to cycle 0
    /// so it aligns with the others. Idempotent: a peer already tracked is left
    /// as-is (re-announce / name change doesn't restart their file).
    pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
        if self.known_peers.contains(&id) {
            return Ok(());
        }
        self.known_peers.insert(id);
        self.pending.new_peers.push(NewPeer {
            id,
            filename: track_filename(name, &id),
        });
        Ok(())
    }

    /// Record one peer's decoded frame for the current cycle. If the peer wasn't
    /// registered yet (write raced ahead of the join event), auto-register it
    /// with an id-only name so no audio is dropped.
    pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
        if !self.known_peers.contains(&id) {
            self.add_peer(id, "")?;
        }
        self.pending
            .peer_frames
            .insert(id, fit(frame, self.frame_samples));
        Ok(())
    }

    /// Buffer a frame of your transmitted mic audio (called from the capture
    /// thread, asynchronously to the mixer cycle). Bounded: oldest samples drop
    /// past [`MAX_MIC_FIFO`] so the mic track's offset can't grow without limit.
    pub fn push_mic(&mut self, frame: &[i16]) {
        self.mic_fifo.extend(frame.iter().copied());
        let overflow = self.mic_fifo.len().saturating_sub(MAX_MIC_FIFO);
        if overflow > 0 {
            self.mic_fifo.drain(..overflow);
        }
    }

    /// Pull exactly `n` mic samples from the FIFO, silence-padded on underrun.
    fn drain_mic(&mut self, n: usize) -> Vec<i16> {
        let take = n.min(self.mic_fifo.len());
        let mut v: Vec<i16> = self.mic_fifo.drain(..take).collect();
        v.resize(n, 0);
        v
    }

    /// Record the finished mixed-bus frame for the current cycle (no-op in
    /// stems-only mode).
    pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
        if self.with_mix {
            self.pending.mix_frame = Some(fit(frame, self.frame_samples));
        }
        Ok(())
    }

    /// Close out the current cycle: every track that wasn't written this cycle
    /// gets one frame of silence, so all tracks advance in lockstep. Call once
    /// per mixer cycle, after the per-track writes.
    pub fn end_cycle(&mut self) -> io::Result<()> {
        let fs = self.frame_samples;
        // Mic: always one frame per cycle, drained from the FIFO (silence on
        // underrun), so it tracks the cycle clock like the peer stems.
        let mic_frame = self.drain_mic(fs);
        let mut pending = std::mem::take(&mut self.pending);
        pending.new_peers.sort_by(|a, b| {
            a.filename
                .cmp(&b.filename)
                .then_with(|| a.id.to_string().cmp(&b.id.to_string()))
        });
        let batch = CycleBatch {
            new_peers: pending.new_peers,
            mic_frame,
            mix_frame: if self.with_mix {
                pending.mix_frame
            } else {
                None
            },
            peer_frames: pending.peer_frames,
        };
        match self.batch_tx.try_send(batch) {
            Ok(()) => Ok(()),
            Err(TrySendError::Full(batch)) => {
                for peer in &batch.new_peers {
                    self.known_peers.remove(&peer.id);
                }
                self.dropped_cycles = self.dropped_cycles.saturating_add(1);
                if self.dropped_cycles == 1
                    || self.dropped_cycles.is_multiple_of(DROP_LOG_INTERVAL_CYCLES)
                {
                    crate::log_msg(&format!(
                        "multitrack recording: writer queue full; dropped {} cycle(s)",
                        self.dropped_cycles
                    ));
                }
                Ok(())
            }
            Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
                io::ErrorKind::BrokenPipe,
                "multitrack writer thread stopped",
            )),
        }
    }

    /// Finalize every track's WAV header. Consumes the recorder.
    pub fn finalize(self) -> io::Result<()> {
        let Self {
            dir: _,
            frame_samples: _,
            known_peers: _,
            mic_fifo: _,
            with_mix: _,
            batch_tx,
            writer_thread,
            dropped_cycles: _,
            pending: _,
        } = self;
        drop(batch_tx);
        writer_thread
            .join()
            .unwrap_or_else(|_| Err(io::Error::other("multitrack writer thread panicked")))
    }
}

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

    fn an_id() -> EndpointId {
        SecretKey::generate().public()
    }

    /// Samples of PCM data in a finished WAV file: (len - 44-byte header) / 2.
    fn wav_samples(path: &Path) -> usize {
        let len = std::fs::metadata(path).unwrap().len() as usize;
        (len - 44) / 2
    }

    fn tmpdir(tag: &str) -> PathBuf {
        let d = std::env::temp_dir().join(format!("ps-mt-{}-{}", tag, std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    #[derive(Default)]
    struct TestWriter {
        samples: Vec<i16>,
    }

    impl SampleWriter for TestWriter {
        fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
            self.samples.extend_from_slice(samples);
            Ok(())
        }

        fn finalize(self) -> io::Result<()> {
            Ok(())
        }
    }

    fn test_writer_state(frame_samples: usize, with_mix: bool) -> WriterState<TestWriter> {
        WriterState {
            dir: PathBuf::new(),
            frame_samples,
            peers: HashMap::new(),
            mic: TestWriter::default(),
            mix: if with_mix {
                Some(TestWriter::default())
            } else {
                None
            },
            cycles_written: 0,
        }
    }

    fn test_batch(
        new_peers: Vec<NewPeer>,
        mic_frame: Vec<i16>,
        mix_frame: Option<Vec<i16>>,
        peer_frames: Vec<(EndpointId, Vec<i16>)>,
    ) -> CycleBatch {
        CycleBatch {
            new_peers,
            mic_frame,
            mix_frame,
            peer_frames: peer_frames.into_iter().collect(),
        }
    }

    #[test]
    fn fit_pads_and_truncates() {
        assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
        assert_eq!(fit(&[1, 2, 3, 4], 2), vec![1, 2]);
        assert_eq!(fit(&[], 3), vec![0, 0, 0]);
    }

    #[test]
    fn track_filename_is_fs_safe_and_disambiguated() {
        let id = an_id();
        let short: String = id.to_string().chars().take(8).collect();
        assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
        // Spaces / punctuation collapse to single dashes, trimmed.
        assert_eq!(
            track_filename("  Bob the Builder! ", &id),
            format!("bob-the-builder-{short}.wav")
        );
        // A name that sanitizes/slugs to nothing falls back to "peer".
        assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
    }

    #[test]
    fn same_second_sessions_get_unique_directories_without_reuse() {
        let base = tmpdir("collision");
        let first = create_session_dir(&base, 1_700_000_000).unwrap();
        std::fs::write(first.join("sentinel"), b"keep me").unwrap();

        let second = create_session_dir(&base, 1_700_000_000).unwrap();

        assert_ne!(second, first);
        assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn apply_batch_advances_existing_tracks_and_back_pads_late_peer() {
        let frame = 3;
        let early = an_id();
        let late = an_id();
        let mut state = test_writer_state(frame, true);
        state.cycles_written = 2;
        state.mic.samples = vec![8; 2 * frame];
        state.mix.as_mut().unwrap().samples = vec![6; 2 * frame];
        state.peers.insert(
            early,
            TestWriter {
                samples: vec![1; 2 * frame],
            },
        );

        let batch = test_batch(
            vec![NewPeer {
                id: late,
                filename: "late.wav".to_string(),
            }],
            vec![9; frame],
            None,
            vec![(early, vec![2; frame]), (late, vec![7; frame])],
        );
        state
            .apply_batch(&batch, |_| Ok(TestWriter::default()))
            .unwrap();

        assert_eq!(state.cycles_written, 3);
        assert_eq!(state.mic.samples.len(), 3 * frame);
        assert_eq!(state.mix.as_ref().unwrap().samples.len(), 3 * frame);
        assert_eq!(
            &state.mix.as_ref().unwrap().samples[2 * frame..],
            &[0, 0, 0]
        );
        assert_eq!(state.peers.get(&early).unwrap().samples.len(), 3 * frame);
        assert_eq!(
            &state.peers.get(&early).unwrap().samples[2 * frame..],
            &[2, 2, 2]
        );
        assert_eq!(
            state.peers.get(&late).unwrap().samples,
            vec![0, 0, 0, 0, 0, 0, 7, 7, 7],
            "late peer is back-padded by completed cycles before this batch"
        );
    }

    #[test]
    fn skipped_batches_keep_all_tracks_equal_length() {
        let frame = 2;
        let p1 = an_id();
        let p2 = an_id();
        let mut state = test_writer_state(frame, true);

        let first = test_batch(
            vec![
                NewPeer {
                    id: p1,
                    filename: "p1.wav".to_string(),
                },
                NewPeer {
                    id: p2,
                    filename: "p2.wav".to_string(),
                },
            ],
            vec![1; frame],
            Some(vec![5; frame]),
            vec![(p1, vec![10; frame]), (p2, vec![20; frame])],
        );
        state
            .apply_batch(&first, |_| Ok(TestWriter::default()))
            .unwrap();

        let _dropped_cycle = test_batch(
            Vec::new(),
            vec![2; frame],
            Some(vec![6; frame]),
            vec![(p1, vec![11; frame])],
        );

        let after_drop = test_batch(
            Vec::new(),
            vec![3; frame],
            None,
            vec![(p1, vec![12; frame])],
        );
        state
            .apply_batch(&after_drop, |_| Ok(TestWriter::default()))
            .unwrap();

        let expected = 2 * frame;
        assert_eq!(state.cycles_written, 2);
        assert_eq!(state.mic.samples.len(), expected);
        assert_eq!(state.mix.as_ref().unwrap().samples.len(), expected);
        assert_eq!(state.peers.get(&p1).unwrap().samples.len(), expected);
        assert_eq!(state.peers.get(&p2).unwrap().samples.len(), expected);
        assert_eq!(
            &state.peers.get(&p2).unwrap().samples[frame..],
            &[0, 0],
            "peer absent from an applied batch gets silence for that cycle"
        );
    }

    #[test]
    fn all_tracks_equal_length_after_n_cycles() {
        let dir = tmpdir("equal");
        let frame = 4; // tiny frame for the test
        let p1 = an_id();
        let p2 = an_id();
        let mut rec = MultitrackRecorder::create(&dir, frame, true).unwrap();
        rec.add_peer(p1, "p1").unwrap();
        rec.add_peer(p2, "p2").unwrap();

        // 3 cycles; p1 talks every cycle, p2 only on cycle 2, mic pushed twice.
        for c in 0..3 {
            rec.write_peer(p1, &[1, 1, 1, 1]).unwrap();
            if c == 2 {
                rec.write_peer(p2, &[2, 2, 2, 2]).unwrap();
            }
            if c < 2 {
                rec.push_mic(&[9, 9, 9, 9]);
            }
            rec.write_mix(&[5, 5, 5, 5]).unwrap();
            rec.end_cycle().unwrap();
        }
        rec.finalize().unwrap();

        let expected = 3 * frame;
        assert_eq!(
            wav_samples(&dir.join("me.wav")),
            expected,
            "mic padded to full length"
        );
        assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
        assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
        assert_eq!(
            wav_samples(&dir.join(track_filename("p2", &p2))),
            expected,
            "silent peer still full length"
        );
    }

    #[test]
    fn late_joiner_is_silence_padded_to_start() {
        let dir = tmpdir("late");
        let frame = 4;
        let early = an_id();
        let late = an_id();
        let mut rec = MultitrackRecorder::create(&dir, frame, false).unwrap();
        rec.add_peer(early, "early").unwrap();

        // 2 cycles before the late peer joins.
        for _ in 0..2 {
            rec.write_peer(early, &[1, 1, 1, 1]).unwrap();
            rec.end_cycle().unwrap();
        }
        // Late peer joins at cycle 2.
        rec.add_peer(late, "late").unwrap();
        for _ in 0..3 {
            rec.write_peer(early, &[1, 1, 1, 1]).unwrap();
            rec.write_peer(late, &[2, 2, 2, 2]).unwrap();
            rec.end_cycle().unwrap();
        }
        rec.finalize().unwrap();

        // Both tracks are the full 5 cycles long (late one was back-padded).
        assert_eq!(
            wav_samples(&dir.join(track_filename("early", &early))),
            5 * frame
        );
        assert_eq!(
            wav_samples(&dir.join(track_filename("late", &late))),
            5 * frame
        );

        // The late track's first 2 cycles are silence, then the real audio.
        let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
        let data = &bytes[44..];
        let read_sample = |i: usize| i16::from_le_bytes([data[i * 2], data[i * 2 + 1]]);
        for i in 0..(2 * frame) {
            assert_eq!(read_sample(i), 0, "leading silence at sample {i}");
        }
        assert_eq!(read_sample(2 * frame), 2, "real audio starts at cycle 2");
    }

    #[test]
    fn stems_only_writes_no_mix_file() {
        let dir = tmpdir("nomix");
        let mut rec = MultitrackRecorder::create(&dir, 4, false).unwrap();
        rec.write_mix(&[1, 2, 3, 4]).unwrap(); // no-op
        rec.end_cycle().unwrap();
        rec.finalize().unwrap();
        assert!(dir.join("me.wav").exists());
        assert!(
            !dir.join("mix.wav").exists(),
            "no mix track in stems-only mode"
        );
    }

    #[cfg(unix)]
    #[test]
    fn async_peer_create_error_surfaces_at_finalize() {
        use std::os::unix::fs::PermissionsExt;

        let dir = tmpdir("asyncerr");
        let mut rec = MultitrackRecorder::create(&dir, 4, false).unwrap();
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();

        rec.add_peer(an_id(), "blocked").unwrap();
        rec.end_cycle().unwrap();
        let result = rec.finalize();

        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
        let err = result.unwrap_err();
        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
        let _ = std::fs::remove_dir_all(&dir);
    }
}
