//! Local call recording to a mono 16-bit PCM WAV file.
//!
//! Records the **full call as you experienced it**: the mixed incoming audio
//! (everyone you hear) summed with your own transmitted mic, into a single mono
//! WAV. Mixing/enqueue is driven by the playout mixer (one
//! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware
//! clock), while disk writes happen on a dedicated writer thread; your mic
//! arrives separately from the capture thread via [`Recorder::push_mic`] and is
//! buffered in a small FIFO so the two independently-clocked streams stay
//! roughly aligned.
//! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag
//! between your voice and the recording is bounded) — harmless for a voice
//! recording, no realtime crackle concern.
//!
//! No external crates: the WAV writer emits the 44-byte canonical header itself
//! and patches the two size fields on the writer thread during
//! [`Recorder::finalize`].

use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};

/// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
const SAMPLE_RATE: u32 = 48_000;
const BITS_PER_SAMPLE: u16 = 16;
const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
const MAX_NAME_ATTEMPTS: usize = 1_000;
const WRITER_QUEUE_FRAMES: usize = 256;
const DROP_LOG_INTERVAL_FRAMES: u64 = 256;

/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
/// if the capture clock runs persistently faster than playout — past this we drop
/// the oldest mic audio rather than let the offset grow without limit.
const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;

/// A minimal canonical PCM WAV writer (mono S16LE). Writes a placeholder header
/// up front, streams sample data, then patches the RIFF + data chunk sizes on
/// [`WavWriter::finalize`].
pub struct WavWriter {
    file: File,
    /// Bytes of PCM data written so far (for the size fields).
    data_bytes: u64,
}

impl WavWriter {
    /// Create the file and write the 44-byte header with zeroed size fields.
    pub fn new(path: &Path) -> io::Result<Self> {
        Self::from_file(File::create(path)?)
    }

    /// Start a WAV in an already-opened file. This lets callers choose atomic
    /// create-new semantics instead of the truncating behavior of `File::create`.
    fn from_file(mut file: File) -> io::Result<Self> {
        file.write_all(&Self::header(0))?;
        Ok(Self {
            file,
            data_bytes: 0,
        })
    }

    /// The 44-byte canonical WAV/PCM header for the given data length in bytes.
    fn header(data_bytes: u32) -> [u8; 44] {
        let byte_rate = SAMPLE_RATE * CHANNELS as u32 * (BITS_PER_SAMPLE as u32 / 8);
        let block_align = CHANNELS * (BITS_PER_SAMPLE / 8);
        let mut h = [0u8; 44];
        h[0..4].copy_from_slice(b"RIFF");
        h[4..8].copy_from_slice(&(36 + data_bytes).to_le_bytes());
        h[8..12].copy_from_slice(b"WAVE");
        h[12..16].copy_from_slice(b"fmt ");
        h[16..20].copy_from_slice(&16u32.to_le_bytes()); // fmt chunk size
        h[20..22].copy_from_slice(&1u16.to_le_bytes()); // PCM
        h[22..24].copy_from_slice(&CHANNELS.to_le_bytes());
        h[24..28].copy_from_slice(&SAMPLE_RATE.to_le_bytes());
        h[28..32].copy_from_slice(&byte_rate.to_le_bytes());
        h[32..34].copy_from_slice(&block_align.to_le_bytes());
        h[34..36].copy_from_slice(&BITS_PER_SAMPLE.to_le_bytes());
        h[36..40].copy_from_slice(b"data");
        h[40..44].copy_from_slice(&data_bytes.to_le_bytes());
        h
    }

    /// Append PCM samples to the data chunk.
    pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
        let added_bytes = u64::try_from(samples.len())
            .ok()
            .and_then(|len| len.checked_mul(2))
            .ok_or_else(|| io::Error::other("WAV sample buffer too large"))?;
        let new_data_bytes = self
            .data_bytes
            .checked_add(added_bytes)
            .ok_or_else(|| io::Error::other("WAV data size overflow"))?;
        if new_data_bytes > MAX_RIFF_DATA_BYTES {
            return Err(io::Error::other("WAV too large for RIFF"));
        }

        let mut buf = Vec::with_capacity(samples.len() * 2);
        for &s in samples {
            buf.extend_from_slice(&s.to_le_bytes());
        }
        self.file.write_all(&buf)?;
        self.data_bytes = new_data_bytes;
        Ok(())
    }

    /// Patch the RIFF + data size fields and flush. Consumes the writer.
    pub fn finalize(mut self) -> io::Result<()> {
        let data_bytes = u32::try_from(self.data_bytes)
            .map_err(|_| io::Error::other("WAV too large for RIFF"))?;
        let riff_size = self
            .data_bytes
            .checked_add(RIFF_DATA_OVERHEAD)
            .and_then(|size| u32::try_from(size).ok())
            .ok_or_else(|| io::Error::other("WAV too large for RIFF"))?;
        self.file.seek(SeekFrom::Start(4))?;
        self.file.write_all(&riff_size.to_le_bytes())?;
        self.file.seek(SeekFrom::Start(40))?;
        self.file.write_all(&data_bytes.to_le_bytes())?;
        self.file.flush()?;
        Ok(())
    }
}

/// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns
/// your transmitted mic with the playout mixer's incoming-mix frames.
pub struct Recorder {
    frame_tx: SyncSender<Vec<i16>>,
    writer_thread: JoinHandle<io::Result<()>>,
    /// Your transmitted mic samples, awaiting alignment with the next mix frame.
    mic_fifo: VecDeque<i16>,
    path: PathBuf,
    dropped_frames: u64,
}

impl Recorder {
    /// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
    /// exist (the caller creates it).
    pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
        let filename = timestamp_filename(now_unix_secs);
        let stem = filename.trim_end_matches(".wav");
        for attempt in 1..=MAX_NAME_ATTEMPTS {
            let name = if attempt == 1 {
                filename.clone()
            } else {
                format!("{stem}-{attempt}.wav")
            };
            let path = dir.join(name);
            match OpenOptions::new().write(true).create_new(true).open(&path) {
                Ok(file) => {
                    let writer = WavWriter::from_file(file)?;
                    let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES);
                    let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx));
                    return Ok(Self {
                        frame_tx,
                        writer_thread,
                        mic_fifo: VecDeque::new(),
                        path,
                        dropped_frames: 0,
                    });
                }
                Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
                Err(e) => return Err(e),
            }
        }
        Err(io::Error::new(
            io::ErrorKind::AlreadyExists,
            "recording filename suffixes exhausted",
        ))
    }

    /// The path being written.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Buffer a frame of your transmitted mic audio. Bounded: if the FIFO exceeds
    /// [`MAX_MIC_FIFO`] (capture outrunning playout), the oldest samples are
    /// dropped so the recording's mic 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);
        }
    }

    /// Write one recording frame: the incoming mix summed (saturating) with the
    /// next aligned slice of buffered mic. Mic samples beyond what's buffered are
    /// treated as silence (you weren't transmitting), so quiet stretches record
    /// the incoming mix alone.
    pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
        let out = mix_with_mic(mixed, &mut self.mic_fifo);
        match self.frame_tx.try_send(out) {
            Ok(()) => Ok(()),
            Err(TrySendError::Full(_)) => {
                self.dropped_frames = self.dropped_frames.saturating_add(1);
                if self.dropped_frames == 1
                    || self.dropped_frames.is_multiple_of(DROP_LOG_INTERVAL_FRAMES)
                {
                    crate::log_msg(&format!(
                        "recording: writer queue full; dropped {} frame(s)",
                        self.dropped_frames
                    ));
                }
                Ok(())
            }
            Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
                io::ErrorKind::BrokenPipe,
                "recording writer thread stopped",
            )),
        }
    }

    /// Finish the file, patching its size fields. Consumes the recorder.
    pub fn finalize(self) -> io::Result<()> {
        let Self {
            frame_tx,
            writer_thread,
            mic_fifo: _,
            path: _,
            dropped_frames: _,
        } = self;
        drop(frame_tx);
        writer_thread
            .join()
            .unwrap_or_else(|_| Err(io::Error::other("recording writer thread panicked")))
    }
}

fn writer_thread_main(mut writer: WavWriter, frame_rx: mpsc::Receiver<Vec<i16>>) -> io::Result<()> {
    let mut first_write_error = None;

    for frame in frame_rx {
        if first_write_error.is_none()
            && let Err(e) = writer.write_samples(&frame)
        {
            first_write_error = Some(e);
        }
    }

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

fn mix_with_mic(mixed: &[i16], mic_fifo: &mut VecDeque<i16>) -> Vec<i16> {
    let mut out = Vec::with_capacity(mixed.len());
    for &m in mixed {
        let mic = mic_fifo.pop_front().unwrap_or(0);
        let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
        out.push(sum as i16);
    }
    out
}

/// Civil date (year, month, day) from a count of days since the Unix epoch.
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
    let z = z + 719_468;
    let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
    let doe = z - era * 146_097; // [0, 146096]
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
    let mp = (5 * doy + 2) / 153; // [0, 11]
    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
    (if m <= 2 { y + 1 } else { y }, m, d)
}

/// A sortable, human-readable recording filename from a Unix timestamp (UTC):
/// `peerspeak-YYYY-MM-DD_HHMMSS.wav`.
pub fn timestamp_filename(unix_secs: u64) -> String {
    let days = (unix_secs / 86_400) as i64;
    let rem = unix_secs % 86_400;
    let (y, m, d) = civil_from_days(days);
    let (h, mi, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
    format!("peerspeak-{y:04}-{m:02}-{d:02}_{h:02}{mi:02}{s:02}.wav")
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::atomic::{AtomicU64, Ordering};

    static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);

    fn unique_temp_dir(prefix: &str) -> PathBuf {
        let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
        std::env::temp_dir().join(format!("{prefix}-{}-{id}", std::process::id()))
    }

    fn read_wav_samples(path: &Path) -> (Vec<u8>, Vec<i16>) {
        let bytes = std::fs::read(path).unwrap();
        let samples = bytes[44..]
            .chunks_exact(2)
            .map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
            .collect();
        (bytes, samples)
    }

    #[test]
    fn timestamp_filename_is_utc_and_padded() {
        // 1_700_000_000 = 2023-11-14 22:13:20 UTC.
        assert_eq!(
            timestamp_filename(1_700_000_000),
            "peerspeak-2023-11-14_221320.wav"
        );
        // Epoch.
        assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
    }

    #[test]
    fn same_second_recordings_get_unique_files_without_truncation() {
        let dir = unique_temp_dir("peerspeak-collision");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
        first.write_frame(&[123, 456]).unwrap();
        let first_path = first.path().to_path_buf();
        first.finalize().unwrap();
        let original = std::fs::read(&first_path).unwrap();

        let second = Recorder::create(&dir, 1_700_000_000).unwrap();
        let second_path = second.path().to_path_buf();
        assert_ne!(second_path, first_path);
        assert_eq!(std::fs::read(&first_path).unwrap(), original);
        second.finalize().unwrap();

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn recorder_thread_writes_mixed_samples_and_header_on_finalize() {
        let dir = unique_temp_dir("peerspeak-recorder-thread");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap();
        let path = recorder.path().to_path_buf();

        recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]);
        recorder.write_frame(&[10, 20, -32700]).unwrap();
        recorder.push_mic(&[300, -300]);
        recorder
            .write_frame(&[0, 1000, i16::MAX, i16::MIN])
            .unwrap();
        recorder.finalize().unwrap();

        let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN];
        let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap();
        let (bytes, samples) = read_wav_samples(&path);

        assert_eq!(&bytes[0..4], b"RIFF");
        assert_eq!(&bytes[8..12], b"WAVE");
        assert_eq!(&bytes[36..40], b"data");
        let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
        let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
        assert_eq!(data, expected_data_bytes);
        assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes);
        assert_eq!(bytes.len(), 44 + expected.len() * 2);
        assert_eq!(samples, expected);

        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn wav_header_round_trips_sizes() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("peerspeak-test-{}.wav", std::process::id()));
        let mut w = WavWriter::new(&path).unwrap();
        // 100 samples = 200 data bytes.
        w.write_samples(&[1234i16; 100]).unwrap();
        w.finalize().unwrap();

        let bytes = std::fs::read(&path).unwrap();
        assert_eq!(&bytes[0..4], b"RIFF");
        assert_eq!(&bytes[8..12], b"WAVE");
        assert_eq!(&bytes[36..40], b"data");
        // RIFF size = 36 + data, data = 200.
        let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
        let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
        assert_eq!(data, 200);
        assert_eq!(riff, 236);
        // File is header + data.
        assert_eq!(bytes.len(), 44 + 200);
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn wav_writer_rejects_data_that_would_overflow_riff_header() {
        let dir = std::env::temp_dir();
        let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id()));
        let mut w = WavWriter::new(&path).unwrap();
        w.data_bytes = MAX_RIFF_DATA_BYTES - 1;
        let before_len = std::fs::metadata(&path).unwrap().len();

        let err = w.write_samples(&[0]).unwrap_err();

        assert_eq!(err.kind(), io::ErrorKind::Other);
        assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1);
        assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len);
        drop(w);
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn mic_is_summed_with_mix_when_present() {
        let mut mic_fifo = VecDeque::from([1000, 2000, 3000]);

        let first = mix_with_mic(&[10, 20], &mut mic_fifo);
        assert_eq!(first, vec![1010, 2020]);
        assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left");

        let second = mix_with_mic(&[0, 0], &mut mic_fifo);
        assert_eq!(second, vec![3000, 0]);
        assert_eq!(
            mic_fifo.len(),
            0,
            "remaining mic sample consumed; rest is silence"
        );
    }

    #[test]
    fn mic_fifo_is_capped() {
        let dir = unique_temp_dir("peerspeak-cap");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let mut r = Recorder::create(&dir, 1_700_000_001).unwrap();
        r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
        assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
        r.finalize().unwrap();

        let _ = std::fs::remove_dir_all(&dir);
    }
}
