//! Bundled upstream sender for the synthetic sync probe.
//!
//! The runner builds the synthetic capture timeline, while this module owns the
//! transport-specific work of pairing fresh audio with each video pulse and
//! streaming `UpstreamMediaBundle` messages to the server.

use anyhow::{Context, Result};
use lesavka_common::{
    audio_transport::{self, AudioTransportProfile, UpstreamAudioCodec},
    lesavka::{AudioPacket, UpstreamMediaBundle, VideoPacket, relay_client::RelayClient},
};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::Instant;
use tonic::{Request, transport::Channel};

use crate::input::camera::CameraConfig;
use crate::uplink_fresh_queue::FreshPacketQueue;

const PROBE_BUNDLE_AUDIO_GRACE: std::time::Duration = std::time::Duration::from_millis(30);
const PROBE_BUNDLE_AUDIO_WINDOW_BEFORE_US: u64 = 120_000;
const PROBE_BUNDLE_AUDIO_WINDOW_AFTER_US: u64 = 40_000;
const PROBE_BUNDLE_MAX_AUDIO_PACKETS: usize = 16;
const PROBE_BUNDLE_SESSION_ID: u64 = 1;

/// Stream synthetic paired media through the same bundled RPC as real webcam calls.
///
/// Inputs: gRPC channel, negotiated camera mode, fresh video/audio queues, and
/// the probe monotonic start instant.
/// Outputs: completed RPC stream or an error from connection, send, or task join.
/// Why: the client-to-RCT probe must exercise post-capture transport without
/// measuring split camera/microphone fallback behavior.
pub async fn run_bundled_probe_stream(
    channel: Channel,
    camera: CameraConfig,
    video_queue: FreshPacketQueue<VideoPacket>,
    audio_queue: FreshPacketQueue<AudioPacket>,
    probe_start: Instant,
) -> Result<()> {
    let bundled_task = tokio::spawn(async move {
        let mut client = RelayClient::new(channel);
        let mut audio_dump = open_debug_dump("LESAVKA_SYNC_PROBE_AUDIO_DUMP")
            .context("opening sync probe audio dump")?;
        let mut send_log = open_debug_dump("LESAVKA_SYNC_PROBE_SEND_LOG")
            .context("opening sync probe send log")?;
        let mut audio_transport = ProbeAudioTransport::from_env();
        let outbound = async_stream::stream! {
            let mut pending_audio = Vec::<AudioPacket>::new();
            let mut audio_done = false;
            let mut video_done = false;
            let mut bundle_seq = 0_u64;
            let mut audio_seq = 0_u64;
            let mut video_seq = 0_u64;

            loop {
                if video_done && audio_done {
                    break;
                }

                tokio::select! {
                    next = video_queue.pop_fresh(), if !video_done => {
                        if next.dropped_stale > 0 {
                            tracing::warn!(
                                dropped_stale = next.dropped_stale,
                                queue_depth = next.queue_depth,
                                "🧪 sync probe video queue dropped stale packets"
                            );
                        }
                        if let Some(mut video) = next.packet {
                            stamp_probe_video_packet(
                                &mut video,
                                &mut video_seq,
                                next.queue_depth,
                                camera.fps,
                                probe_start,
                            );
                            retain_probe_audio_for_video(&mut pending_audio, packet_video_capture_pts_us(&video));
                            collect_probe_audio_grace(
                                &audio_queue,
                                &mut pending_audio,
                                &mut audio_done,
                                &mut audio_seq,
                                &mut audio_transport,
                                audio_dump.as_mut(),
                                probe_start,
                            ).await;
                            retain_probe_audio_for_video(&mut pending_audio, packet_video_capture_pts_us(&video));
                            if pending_audio.is_empty() {
                                tracing::warn!(
                                    video_pts = packet_video_capture_pts_us(&video),
                                    "🧪 sync probe skipped video-only bundle while measuring bundled output delay"
                                );
                                continue;
                            }
                            bundle_seq = bundle_seq.saturating_add(1);
                            let audio = std::mem::take(&mut pending_audio);
                            let (capture_start_us, capture_end_us) =
                                probe_bundle_capture_bounds(Some(&video), &audio);
                            write_probe_send_log(
                                send_log.as_mut(),
                                bundle_seq,
                                probe_start,
                                Some(&video),
                                &audio,
                            );
                            yield build_probe_bundle(
                                PROBE_BUNDLE_SESSION_ID,
                                bundle_seq,
                                &camera,
                                Some(video),
                                audio,
                                capture_start_us,
                                capture_end_us,
                            );
                        } else if next.closed {
                            video_done = true;
                        }
                    }
                    next = audio_queue.pop_fresh(), if !audio_done => {
                        if next.dropped_stale > 0 {
                            tracing::warn!(
                                dropped_stale = next.dropped_stale,
                                queue_depth = next.queue_depth,
                                "🧪 sync probe audio queue dropped stale packets"
                            );
                        }
                        if let Some(mut packet) = next.packet {
                            stamp_probe_audio_packet(
                                &mut packet,
                                &mut audio_seq,
                                next.queue_depth,
                                probe_start,
                            );
                            if let Some(packet) = audio_transport.encode_packet(packet) {
                                write_probe_audio_dump(audio_dump.as_mut(), &packet);
                                pending_audio.push(packet);
                            }
                            retain_newest_probe_audio(&mut pending_audio);
                        } else if next.closed {
                            audio_done = true;
                        }
                    }
                }
            }
            if let Some(file) = audio_dump.as_mut() {
                let _ = file.flush();
            }
            if let Some(file) = send_log.as_mut() {
                let _ = file.flush();
            }
        };
        let mut response = client
            .stream_webcam_media(Request::new(outbound))
            .await
            .context("starting bundled sync probe webcam stream")?;
        while response.get_mut().message().await.transpose().is_some() {}
        Ok::<(), anyhow::Error>(())
    });

    bundled_task
        .await
        .context("joining bundled sync probe stream")?
        .context("bundled sync probe task failed")
}

/// Build one outgoing synthetic A/V bundle after local pairing is complete.
///
/// Inputs: bundle identity, negotiated camera profile, paired video/audio, and
/// precomputed capture bounds.
/// Outputs: the exact `UpstreamMediaBundle` yielded to the server RPC.
/// Why: the server's HEVC path depends on video codec metadata, audio format,
/// and capture bounds staying attached to the same physical bundle that leaves
/// the client.
fn build_probe_bundle(
    session_id: u64,
    seq: u64,
    camera: &CameraConfig,
    video: Option<VideoPacket>,
    audio: Vec<AudioPacket>,
    capture_start_us: u64,
    capture_end_us: u64,
) -> UpstreamMediaBundle {
    let profile = audio
        .first()
        .map(audio_transport::packet_audio_profile)
        .unwrap_or_else(AudioTransportProfile::pcm_s16le);
    let mut bundle = UpstreamMediaBundle {
        session_id,
        seq,
        capture_start_us,
        capture_end_us,
        video,
        audio,
        audio_sample_rate: profile.sample_rate,
        audio_channels: profile.channels,
        audio_encoding: profile.encoding as i32,
        video_width: camera.width,
        video_height: camera.height,
        video_fps: camera.fps,
    };
    audio_transport::mark_bundle_audio_profile(&mut bundle, profile);
    bundle
}

/// Drain one short audio grace window when a video packet arrives first.
///
/// Inputs: audio queue, pending audio buffer, sequence state, debug dump, and
/// the probe clock.
/// Outputs: at most one newly stamped audio packet in `pending_audio`.
/// Why: real bundled transport pairs nearby mic and camera captures before
/// sending; the synthetic probe needs to mimic that path closely.
async fn collect_probe_audio_grace(
    audio_queue: &FreshPacketQueue<AudioPacket>,
    pending_audio: &mut Vec<AudioPacket>,
    audio_done: &mut bool,
    audio_seq: &mut u64,
    audio_transport: &mut ProbeAudioTransport,
    audio_dump: Option<&mut File>,
    probe_start: Instant,
) {
    if *audio_done || !pending_audio.is_empty() {
        return;
    }
    let Ok(next) = tokio::time::timeout(PROBE_BUNDLE_AUDIO_GRACE, audio_queue.pop_fresh()).await
    else {
        return;
    };
    if let Some(mut packet) = next.packet {
        stamp_probe_audio_packet(&mut packet, audio_seq, next.queue_depth, probe_start);
        if let Some(packet) = audio_transport.encode_packet(packet) {
            write_probe_audio_dump(audio_dump, &packet);
            pending_audio.push(packet);
        }
        retain_newest_probe_audio(pending_audio);
    } else if next.closed {
        *audio_done = true;
    }
}

/// Runtime audio transport for synthetic probe packets.
///
/// Inputs: `LESAVKA_UPLINK_AUDIO_CODEC` and installed GStreamer plugins.
/// Outputs: packet bytes and metadata matching the selected upstream audio
/// route. Why: the blind client->server->RCT test must exercise the same Opus
/// compression path as the live microphone, not silently measure PCM.
struct ProbeAudioTransport {
    encoder: Option<crate::input::audio_codec::OpusPacketEncoder>,
}

impl ProbeAudioTransport {
    fn from_env() -> Self {
        match crate::input::audio_codec::requested_upstream_audio_codec_from_env() {
            UpstreamAudioCodec::PcmS16le => Self { encoder: None },
            UpstreamAudioCodec::Opus => match crate::input::audio_codec::OpusPacketEncoder::new() {
                Ok(encoder) => {
                    tracing::info!("🧪 sync probe Opus audio transport enabled");
                    Self {
                        encoder: Some(encoder),
                    }
                }
                Err(err) => {
                    tracing::warn!(
                        "🧪⚠️ sync probe Opus requested but unavailable ({err:#}); falling back to PCM"
                    );
                    Self { encoder: None }
                }
            },
        }
    }

    fn encode_packet(&mut self, mut packet: AudioPacket) -> Option<AudioPacket> {
        audio_transport::mark_packet_pcm_s16le(&mut packet);
        let Some(encoder) = self.encoder.as_mut() else {
            return Some(packet);
        };
        match encoder.encode_packet(packet.clone()) {
            Ok(Some(encoded)) => Some(encoded),
            Ok(None) => None,
            Err(err) => {
                tracing::warn!(
                    "🧪⚠️ sync probe Opus encode failed; sending PCM fallback for this packet: {err:#}"
                );
                Some(packet)
            }
        }
    }
}

/// Stamp one synthetic audio packet with client-side transport telemetry.
///
/// Inputs: mutable packet, sequence counter, queue depth, and probe clock.
/// Outputs: packet sidecar fields used by server freshness telemetry.
/// Why: server diagnostics compare capture, send, receive, and sink timing, so
/// synthetic packets need the same metadata as physical capture packets.
fn stamp_probe_audio_packet(
    packet: &mut AudioPacket,
    seq: &mut u64,
    queue_depth: usize,
    probe_start: Instant,
) {
    *seq = seq.saturating_add(1);
    let capture_pts_us = packet.pts;
    let send_pts_us = probe_elapsed_us(probe_start).max(capture_pts_us);
    packet.seq = *seq;
    packet.client_capture_pts_us = capture_pts_us;
    packet.client_send_pts_us = send_pts_us;
    packet.client_queue_depth = queue_depth.try_into().unwrap_or(u32::MAX);
    packet.client_queue_age_ms = packet_age_ms(capture_pts_us, send_pts_us);
}

/// Stamp one synthetic video packet with client-side transport telemetry.
///
/// Inputs: mutable packet, sequence counter, queue depth, negotiated FPS, and
/// probe clock.
/// Outputs: packet sidecar fields used by server freshness telemetry.
/// Why: video freshness must be measured against generated capture PTS, not a
/// later wall-clock moment after MJPEG encoding or gRPC buffering.
fn stamp_probe_video_packet(
    packet: &mut VideoPacket,
    seq: &mut u64,
    queue_depth: usize,
    fps: u32,
    probe_start: Instant,
) {
    *seq = seq.saturating_add(1);
    let capture_pts_us = packet.pts;
    let send_pts_us = probe_elapsed_us(probe_start).max(capture_pts_us);
    packet.seq = *seq;
    packet.effective_fps = fps;
    packet.client_capture_pts_us = capture_pts_us;
    packet.client_send_pts_us = send_pts_us;
    packet.client_queue_depth = queue_depth.try_into().unwrap_or(u32::MAX);
    packet.client_queue_age_ms = packet_age_ms(capture_pts_us, send_pts_us);
}

/// Return elapsed probe time in microseconds on the synthetic capture clock.
///
/// Inputs: probe monotonic start instant.
/// Outputs: saturating microsecond timestamp.
/// Why: send-age telemetry must share the same origin as the generated media
/// PTS so local queue age is not inflated by setup work.
fn probe_elapsed_us(probe_start: Instant) -> u64 {
    probe_start.elapsed().as_micros().min(u64::MAX as u128) as u64
}

/// Calculate client-local queue age for one packet.
///
/// Inputs: packet capture PTS and send PTS in microseconds.
/// Outputs: saturating age in milliseconds.
/// Why: the server freshness budget drops already-stale bundles before they can
/// distort RCT sync evidence.
fn packet_age_ms(capture_pts_us: u64, send_pts_us: u64) -> u32 {
    (send_pts_us.saturating_sub(capture_pts_us) / 1_000)
        .try_into()
        .unwrap_or(u32::MAX)
}

/// Append raw audio bytes when a debug dump is requested.
///
/// Inputs: optional dump file and one stamped audio packet.
/// Outputs: best-effort write to the dump file.
/// Why: audio decoding failures in the RCT analyzer are easier to debug when
/// the exact sent PCM can be inspected.
fn write_probe_audio_dump(file: Option<&mut File>, packet: &AudioPacket) {
    if let Some(file) = file {
        let _ = file.write_all(&packet.data);
    }
}

/// Write one client-side bundled send telemetry row.
///
/// Inputs: optional log file, bundle sequence, probe clock, and media packets.
/// Outputs: JSONL sidecar used by manual transport probes.
/// Why: when RCT freshness fails, this shows whether the client yielded stale
/// synthetic media or whether delay accumulated after gRPC accepted the bundle.
fn write_probe_send_log(
    file: Option<&mut File>,
    bundle_seq: u64,
    probe_start: Instant,
    video: Option<&VideoPacket>,
    audio: &[AudioPacket],
) {
    let Some(file) = file else {
        return;
    };
    let send_elapsed_us = probe_elapsed_us(probe_start);
    let unix_ns = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|duration| duration.as_nanos().min(u64::MAX as u128) as u64)
        .unwrap_or_default();
    let video_pts_us = video.map(packet_video_capture_pts_us);
    let video_bytes = video.map(|packet| packet.data.len()).unwrap_or_default();
    let audio_first_pts_us = audio.first().map(packet_audio_capture_pts_us);
    let audio_last_pts_us = audio.last().map(packet_audio_capture_pts_us);
    let audio_bytes: usize = audio.iter().map(|packet| packet.data.len()).sum();
    let max_capture_pts_us = video_pts_us
        .into_iter()
        .chain(audio_last_pts_us)
        .max()
        .unwrap_or_default();
    let local_age_ms = send_elapsed_us.saturating_sub(max_capture_pts_us) as f64 / 1000.0;
    let _ = writeln!(
        file,
        "{{\"schema\":\"lesavka.sync-probe-send.v1\",\"bundle_seq\":{},\"send_unix_ns\":{},\"send_elapsed_us\":{},\"video_capture_pts_us\":{},\"video_bytes\":{},\"audio_packets\":{},\"audio_first_capture_pts_us\":{},\"audio_last_capture_pts_us\":{},\"audio_bytes\":{},\"local_age_ms\":{:.3}}}",
        bundle_seq,
        unix_ns,
        send_elapsed_us,
        optional_u64_json(video_pts_us),
        video_bytes,
        audio.len(),
        optional_u64_json(audio_first_pts_us),
        optional_u64_json(audio_last_pts_us),
        audio_bytes,
        local_age_ms,
    );
}

/// Format an optional integer for the debug JSONL writer.
///
/// Inputs: optional unsigned integer.
/// Outputs: JSON number text or `null`.
/// Why: the send log is intentionally dependency-free so the manual probe can
/// keep running even if serde support changes.
fn optional_u64_json(value: Option<u64>) -> String {
    value
        .map(|value| value.to_string())
        .unwrap_or_else(|| "null".to_string())
}

/// Keep only the newest bounded audio packets for the next video bundle.
///
/// Inputs: mutable pending audio packet list.
/// Outputs: list trimmed in place.
/// Why: stale audio continuity is less useful than preserving a live capture
/// relationship with the next flash frame.
fn retain_newest_probe_audio(pending_audio: &mut Vec<AudioPacket>) {
    if pending_audio.len() > PROBE_BUNDLE_MAX_AUDIO_PACKETS {
        let dropped = pending_audio.len() - PROBE_BUNDLE_MAX_AUDIO_PACKETS;
        pending_audio.drain(..dropped);
    }
}

/// Retain only audio packets close enough to travel with one video frame.
///
/// Inputs: pending audio packet list and video capture PTS.
/// Outputs: list filtered and capped in place.
/// Why: client->server sync testing should send physically plausible bundles,
/// not arbitrary pending audio that only happened to be in memory.
fn retain_probe_audio_for_video(pending_audio: &mut Vec<AudioPacket>, video_pts_us: u64) {
    let min_pts = video_pts_us.saturating_sub(PROBE_BUNDLE_AUDIO_WINDOW_BEFORE_US);
    let max_pts = video_pts_us.saturating_add(PROBE_BUNDLE_AUDIO_WINDOW_AFTER_US);
    pending_audio.retain(|packet| {
        let pts = packet_audio_capture_pts_us(packet);
        pts >= min_pts && pts <= max_pts
    });
    retain_newest_probe_audio(pending_audio);
}

/// Return capture PTS bounds for one outgoing bundle.
///
/// Inputs: optional video packet and audio packets.
/// Outputs: `(start_us, end_us)` capture span for the bundle message.
/// Why: the server uses this span as a plausibility check before trusting the
/// client capture clock for lip-sync scheduling.
fn probe_bundle_capture_bounds(video: Option<&VideoPacket>, audio: &[AudioPacket]) -> (u64, u64) {
    let mut start = u64::MAX;
    let mut end = 0_u64;
    if let Some(video) = video {
        let pts = packet_video_capture_pts_us(video);
        start = start.min(pts);
        end = end.max(pts);
    }
    for packet in audio {
        let pts = packet_audio_capture_pts_us(packet);
        start = start.min(pts);
        end = end.max(pts);
    }
    if start == u64::MAX {
        let now = crate::live_capture_clock::capture_pts_us();
        return (now, now);
    }
    (start, end.max(start))
}

/// Read the capture PTS from an audio packet sidecar or legacy PTS field.
///
/// Inputs: one audio packet.
/// Outputs: capture timestamp in microseconds.
/// Why: old callers may only populate `pts`, while the bundled path prefers the
/// explicit client capture sidecar.
fn packet_audio_capture_pts_us(packet: &AudioPacket) -> u64 {
    if packet.client_capture_pts_us == 0 {
        packet.pts
    } else {
        packet.client_capture_pts_us
    }
}

/// Read the capture PTS from a video packet sidecar or legacy PTS field.
///
/// Inputs: one video packet.
/// Outputs: capture timestamp in microseconds.
/// Why: the probe sender and server both need one stable clock field while
/// older test packets may still use `pts` directly.
fn packet_video_capture_pts_us(packet: &VideoPacket) -> u64 {
    if packet.client_capture_pts_us == 0 {
        packet.pts
    } else {
        packet.client_capture_pts_us
    }
}

/// Open an optional debug sidecar path named by an environment variable.
///
/// Inputs: environment variable name.
/// Outputs: `None` when unset, otherwise a writable file handle.
/// Why: manual transport probes need rich artifacts, but normal probe runs
/// should not pay for debug file creation unless explicitly requested.
fn open_debug_dump(env_var: &str) -> Result<Option<File>> {
    let Some(path) = std::env::var_os(env_var) else {
        return Ok(None);
    };
    let path = PathBuf::from(path);
    let file = File::create(&path)
        .with_context(|| format!("creating debug dump at {}", path.display()))?;
    Ok(Some(file))
}

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