pub struct ClipTap {
    buf: Vec<u8>,
    tag: &'static str,
    next_dump: Instant,
    period: Duration,
}

impl ClipTap {
    pub fn new(tag: &'static str, period: Duration) -> Self {
        Self {
            buf: Vec::with_capacity(260_000),
            tag,
            next_dump: Instant::now() + period,
            period,
        }
    }
    /// Keeps `feed` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
    /// Inputs are the typed parameters; output is the return value or side effect.
    pub fn feed(&mut self, bytes: &[u8]) {
        self.buf.extend_from_slice(bytes);
        if self.buf.len() > 256_000 {
            self.buf.drain(..self.buf.len() - 256_000);
        }
        if Instant::now() >= self.next_dump {
            self.flush();
            self.next_dump += self.period;
        }
    }
    /// Keeps `flush` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
    /// Inputs are the typed parameters; output is the return value or side effect.
    pub fn flush(&mut self) {
        if self.buf.is_empty() {
            return;
        }
        let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
        let path = format!("/tmp/{}-{}.s16le", self.tag, ts);
        let _ = std::fs::write(&path, &self.buf);
        self.buf.clear();
    }
}
impl Drop for ClipTap {
    fn drop(&mut self) {
        self.flush()
    }
}

const VOICE_SAMPLE_RATE: u64 = 48_000;
const VOICE_CHANNELS: usize = 2;
const VOICE_SAMPLE_BYTES: usize = std::mem::size_of::<i16>();

// ────────────────────── microphone sink  ────────────────────────────────
pub struct Voice {
    appsrc: gst_app::AppSrc,
    _pipe: gst::Pipeline, // keep pipeline alive
    clock_aligned: bool,
    tap: ClipTap,
    opus_decoder: Option<opus_decode::OpusPacketDecoder>,
}

impl Drop for Voice {
    fn drop(&mut self) {
        let _ = self._pipe.set_state(gst::State::Null);
    }
}

fn voice_input_caps() -> gst::Caps {
    gst::Caps::builder("audio/x-raw")
        .field("format", "S16LE")
        .field("layout", "interleaved")
        .field("rate", VOICE_SAMPLE_RATE as i32)
        .field("channels", VOICE_CHANNELS as i32)
        .build()
}

fn voice_sink_buffer_time_us() -> i64 {
    positive_voice_sink_timing_env("LESAVKA_UAC_BUFFER_TIME_US", 120_000)
}

fn voice_sink_latency_time_us() -> i64 {
    positive_voice_sink_timing_env("LESAVKA_UAC_LATENCY_TIME_US", 40_000)
}

fn voice_sink_compensation_us() -> i64 {
    std::env::var("LESAVKA_UAC_COMPENSATION_US")
        .ok()
        .and_then(|value| value.trim().parse::<i64>().ok())
        .filter(|value| *value >= 0)
        .unwrap_or_else(default_voice_sink_compensation_us)
}

/// Keeps `default_voice_sink_compensation_us` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
fn default_voice_sink_compensation_us() -> i64 {
    let cfg = crate::camera::current_camera_config();
    if cfg.output == crate::camera::CameraOutput::Hdmi {
        // Bench captures on the real HDMI/UAC host path still show gadget audio
        // arriving about 205 ms ahead of the HDMI camera feed once the shared
        // upstream playout is stable, so keep a modest default audio holdback.
        non_negative_voice_sink_timing_env("LESAVKA_UAC_HDMI_COMPENSATION_US", 205_000)
    } else {
        0
    }
}

fn positive_voice_sink_timing_env(name: &str, default: i64) -> i64 {
    std::env::var(name)
        .ok()
        .and_then(|value| value.trim().parse::<i64>().ok())
        .filter(|value| *value > 0)
        .unwrap_or(default)
}

fn non_negative_voice_sink_timing_env(name: &str, default: i64) -> i64 {
    std::env::var(name)
        .ok()
        .and_then(|value| value.trim().parse::<i64>().ok())
        .filter(|value| *value >= 0)
        .unwrap_or(default)
}

/// Keeps `voice_sink_session_clock_align_enabled` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
fn voice_sink_session_clock_align_enabled() -> bool {
    std::env::var("LESAVKA_UAC_SESSION_CLOCK_ALIGN")
        .ok()
        .map(|value| {
            let trimmed = value.trim();
            !(trimmed.eq_ignore_ascii_case("0")
                || trimmed.eq_ignore_ascii_case("false")
                || trimmed.eq_ignore_ascii_case("no")
                || trimmed.eq_ignore_ascii_case("off"))
        })
        .unwrap_or(true)
}

fn voice_sink_delay_queue_enabled(compensation_us: i64) -> bool {
    compensation_us > 0
}

fn voice_appsrc_max_buffers() -> u64 {
    positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_BUFFERS", 16)
}

fn voice_appsrc_max_bytes() -> u64 {
    positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_BYTES", 65_536)
}

fn voice_appsrc_max_time_ns() -> u64 {
    positive_voice_appsrc_limit_env("LESAVKA_UAC_APP_MAX_TIME_NS", 200_000_000)
}

fn voice_appsrc_leaky_type() -> String {
    std::env::var("LESAVKA_UAC_APP_LEAKY_TYPE")
        .ok()
        .map(|value| value.trim().to_ascii_lowercase())
        .filter(|value| matches!(value.as_str(), "downstream" | "upstream" | "none"))
        .unwrap_or_else(|| "downstream".to_string())
}

fn positive_voice_appsrc_limit_env(name: &str, default: u64) -> u64 {
    std::env::var(name)
        .ok()
        .and_then(|value| value.trim().parse::<u64>().ok())
        .filter(|value| *value > 0)
        .unwrap_or(default)
}

#[cfg(not(coverage))]
/// Keeps `configure_voice_appsrc` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
fn configure_voice_appsrc(appsrc: &gst_app::AppSrc) {
    use gst::prelude::*;

    appsrc.set_property("block", false);
    if appsrc.has_property("max-buffers", None) {
        appsrc.set_property("max-buffers", voice_appsrc_max_buffers());
    }
    if appsrc.has_property("max-bytes", None) {
        appsrc.set_property("max-bytes", voice_appsrc_max_bytes());
    }
    if appsrc.has_property("max-time", None) {
        appsrc.set_property("max-time", voice_appsrc_max_time_ns());
    }
    if appsrc.has_property("leaky-type", None) {
        appsrc.set_property_from_str("leaky-type", &voice_appsrc_leaky_type());
    }
}

impl Voice {
    #[cfg(coverage)]
    /// Keeps `new` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
    /// Inputs are the typed parameters; output is the return value or side effect.
    pub async fn new(_alsa_dev: &str) -> anyhow::Result<Self> {
        gst::init().context("gst init")?;

        let pipeline = gst::Pipeline::new();
        let appsrc = gst::ElementFactory::make("appsrc")
            .build()
            .context("make appsrc")?
            .downcast::<gst_app::AppSrc>()
            .expect("appsrc");
        appsrc.set_format(gst::Format::Time);
        appsrc.set_is_live(true);
        appsrc.set_caps(Some(&voice_input_caps()));

        let sink = gst::ElementFactory::make("fakesink")
            .build()
            .context("make fakesink")?;
        pipeline.add_many(&[appsrc.upcast_ref(), &sink])?;
        gst::Element::link_many(&[appsrc.upcast_ref(), &sink])?;
        start_pipeline_or_reset(&pipeline, "starting voice pipeline")?;

        Ok(Self {
            appsrc,
            _pipe: pipeline,
            clock_aligned: false,
            tap: ClipTap::new("voice", Duration::from_secs(60)),
            opus_decoder: opus_decode::OpusPacketDecoder::new().ok(),
        })
    }

    #[cfg(not(coverage))]
    /// Keeps `new` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
    /// Inputs are the typed parameters; output is the return value or side effect.
    pub async fn new(alsa_dev: &str) -> anyhow::Result<Self> {
        use gst::prelude::*;

        gst::init().context("gst init")?;

        // pipeline
        let pipeline = gst::Pipeline::new();

        // elements
        let appsrc = gst::ElementFactory::make("appsrc")
            .build()
            .context("make appsrc")?
            .downcast::<gst_app::AppSrc>()
            .unwrap();

        // dedicated AppSrc helpers exist and avoid the needless `?`
        appsrc.set_caps(Some(&voice_input_caps()));
        appsrc.set_format(gst::Format::Time);
        appsrc.set_is_live(true);
        configure_voice_appsrc(&appsrc);

        let convert = gst::ElementFactory::make("audioconvert")
            .build()
            .context("make audioconvert")?;
        let resample = gst::ElementFactory::make("audioresample")
            .build()
            .context("make audioresample")?;
        let caps = gst::Caps::builder("audio/x-raw")
            .field("format", "S16LE")
            .field("channels", VOICE_CHANNELS as i32)
            .field("rate", VOICE_SAMPLE_RATE as i32)
            .build();
        let capsfilter = gst::ElementFactory::make("capsfilter")
            .property("caps", &caps)
            .build()
            .context("make capsfilter")?;
        let level = gst::ElementFactory::make("level")
            .name("voice_level_pre_queue")
            .property("interval", 1_000_000_000u64)
            .property("message", true)
            .build()
            .context("make voice pre-queue level probe")?;
        let alsa_sink = gst::ElementFactory::make("alsasink")
            .build()
            .context("make alsasink")?;
        let delay_queue = gst::ElementFactory::make("queue")
            .build()
            .context("make voice delay queue")?;
        let post_level = gst::ElementFactory::make("level")
            .name("voice_level_post_queue")
            .property("interval", 1_000_000_000u64)
            .property("message", true)
            .build()
            .context("make voice post-queue level probe")?;
        let buffer_time_us = voice_sink_buffer_time_us();
        let latency_time_us = voice_sink_latency_time_us();
        let compensation_us = voice_sink_compensation_us();
        let clock_align_enabled = voice_sink_session_clock_align_enabled();
        let compensation_ns = (compensation_us.max(0) as u64).saturating_mul(1_000);
        let delay_queue_enabled = voice_sink_delay_queue_enabled(compensation_us);

        alsa_sink.set_property("device", alsa_dev);
        alsa_sink.set_property("sync", clock_align_enabled);
        // The UAC gadget is a live sink, so async preroll buys us little and
        // has repeatedly stranded post-queue buffers behind a sink that never
        // fully arms. Keep timestamp sync, but let the sink start immediately.
        alsa_sink.set_property("async", false);
        alsa_sink.set_property("enable-last-sample", false);
        alsa_sink.set_property("provide-clock", false);
        alsa_sink.set_property("buffer-time", buffer_time_us);
        alsa_sink.set_property("latency-time", latency_time_us);
        if delay_queue_enabled {
            delay_queue.set_property("max-size-buffers", 0u32);
            delay_queue.set_property("max-size-bytes", 0u32);
            delay_queue.set_property("max-size-time", compensation_ns);
            delay_queue.set_property("min-threshold-time", compensation_ns);
        }
        tracing::info!(
            %alsa_dev,
            buffer_time_us,
            latency_time_us,
            compensation_us,
            delay_queue_enabled,
            clock_align_enabled,
            "🎤 UAC sink call-stability timing armed"
        );
        if clock_align_enabled {
            crate::media_timing::prepare_pipeline_clock_sync(&pipeline);
        }

        if delay_queue_enabled {
            pipeline.add_many([
                appsrc.upcast_ref(),
                &convert,
                &resample,
                &capsfilter,
                &level,
                &delay_queue,
                &post_level,
                &alsa_sink,
            ])?;
        } else {
            pipeline.add_many([
                appsrc.upcast_ref(),
                &convert,
                &resample,
                &capsfilter,
                &level,
                &post_level,
                &alsa_sink,
            ])?;
        }
        appsrc.link(&convert)?;
        if delay_queue_enabled {
            gst::Element::link_many([
                &convert,
                &resample,
                &capsfilter,
                &level,
                &delay_queue,
                &post_level,
                &alsa_sink,
            ])?;
        } else {
            gst::Element::link_many([
                &convert,
                &resample,
                &capsfilter,
                &level,
                &post_level,
                &alsa_sink,
            ])?;
        }

        let bus = pipeline.bus().context("voice pipeline bus")?;

        // underrun ≠ error – just show a warning
        // let _id = alsa_sink.connect("underrun", false, |_| {
        //     tracing::warn!("⚠️  USB playback underrun – host muted/not reading");
        //     None
        // });

        start_pipeline_or_reset(&pipeline, "starting voice pipeline")?;
        spawn_pipeline_bus_logger(bus, "voice", "🎤 voice pipeline ▶️");

        Ok(Self {
            appsrc,
            _pipe: pipeline,
            clock_aligned: !clock_align_enabled,
            tap: ClipTap::new("voice", Duration::from_secs(60)),
            opus_decoder: opus_decode::OpusPacketDecoder::new().ok(),
        })
    }

    /// Keeps `push` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
    /// Inputs are the typed parameters; output is the return value or side effect.
    pub fn push(&mut self, pkt: &AudioPacket) {
        let decoded;
        let pkt = if lesavka_common::audio_transport::packet_is_raw_pcm_s16le(pkt) {
            pkt
        } else if lesavka_common::audio_transport::normalize_audio_encoding(pkt.encoding)
            == lesavka_common::lesavka::AudioEncoding::Opus
        {
            let Some(decoder) = self.opus_decoder.as_mut() else {
                tracing::warn!(
                    target: "lesavka_server::audio",
                    pts = pkt.pts,
                    bytes = pkt.data.len(),
                    encoding = pkt.encoding,
                    "🎤⚠️  dropping Opus microphone packet because opusdec is unavailable"
                );
                return;
            };
            match decoder.decode_packet(pkt) {
                Ok(Some(packet)) => {
                    decoded = packet;
                    &decoded
                }
                Ok(None) => {
                    tracing::debug!(
                        target: "lesavka_server::audio",
                        pts = pkt.pts,
                        bytes = pkt.data.len(),
                        "🎤 Opus decoder produced no PCM yet"
                    );
                    return;
                }
                Err(err) => {
                    tracing::warn!(
                        target: "lesavka_server::audio",
                        %err,
                        pts = pkt.pts,
                        bytes = pkt.data.len(),
                        "🎤⚠️  dropping Opus microphone packet after decode failure"
                    );
                    return;
                }
            }
        } else {
            tracing::warn!(
                target: "lesavka_server::audio",
                pts = pkt.pts,
                bytes = pkt.data.len(),
                encoding = pkt.encoding,
                "🎤⚠️  dropping upstream microphone packet with unsupported audio encoding"
            );
            return;
        };

        self.tap.feed(&pkt.data);
        if !self.clock_aligned {
            crate::media_timing::align_pipeline_to_session_clock(&self._pipe, pkt.pts);
            self.clock_aligned = true;
        }

        let mut buf = gst::Buffer::from_slice(pkt.data.clone());
        if let Some(meta) = buf.get_mut() {
            let ts = gst::ClockTime::from_useconds(pkt.pts);
            meta.set_pts(Some(ts));
            meta.set_dts(Some(ts));
            meta.set_duration(Some(gst::ClockTime::from_useconds(
                voice_packet_duration_us(pkt.data.len()),
            )));
        }

        if let Err(err) = self.appsrc.push_buffer(buf) {
            tracing::warn!(
                target: "lesavka_server::audio",
                %err,
                pts = pkt.pts,
                bytes = pkt.data.len(),
                "🎤⚠️  UAC appsrc rejected upstream microphone packet"
            );
        }
    }
    pub fn finish(&mut self) {
        self.tap.flush();
        let _ = self.appsrc.end_of_stream();
    }
}

fn voice_packet_duration_us(bytes: usize) -> u64 {
    let bytes_per_frame = VOICE_CHANNELS * VOICE_SAMPLE_BYTES;
    let frames = bytes / bytes_per_frame.max(1);
    (((frames as u128 * 1_000_000u128) / VOICE_SAMPLE_RATE as u128).min(u64::MAX as u128)
        as u64)
        .max(1)
}

#[cfg(test)]
#[path = "voice_input/tests/mod.rs"]
mod voice_sink_timing_tests;
