//! Analyze captured upstream sync-probe media for audio/video skew and drift.

mod media_extract;
mod onset_detection;
mod report;
#[cfg(test)]
pub(super) mod test_support;

use anyhow::{Context, Result, bail};
use std::path::Path;

use media_extract::{
    extract_audio_samples, extract_video_brightness, extract_video_colors, extract_video_timestamps,
};
use onset_detection::{
    DEFAULT_AUDIO_SAMPLE_RATE_HZ, PulseSegment, correlate_coded_segments, correlate_segments,
    detect_audio_segments, detect_coded_audio_segments, detect_color_coded_video_segments,
    detect_video_segments,
};

pub use onset_detection::{detect_audio_onsets, detect_video_onsets};
pub use report::{
    SyncAnalysisOptions, SyncAnalysisReport, SyncAnalysisVerdict, SyncCalibrationRecommendation,
    SyncEventPair,
};

/// Analyzes a captured upstream sync-probe file by extracting video and audio
/// pulses, then correlating them into skew and drift metrics.
pub fn analyze_capture(
    capture_path: &Path,
    options: &SyncAnalysisOptions,
) -> Result<SyncAnalysisReport> {
    let raw_timestamps = extract_video_timestamps(capture_path)?;
    let (video_segments, coded_video_events) = if options.event_width_codes.is_empty() {
        let brightness = extract_video_brightness(capture_path)?;
        let timestamps = reconcile_video_timestamps(raw_timestamps, brightness.len())?;
        (detect_video_segments(&timestamps, &brightness)?, false)
    } else {
        let colors = extract_video_colors(capture_path)?;
        let timestamps = reconcile_video_timestamps(raw_timestamps.clone(), colors.len())?;
        let segments = detect_color_coded_video_segments(
            &timestamps,
            &colors,
            &options.event_width_codes,
            options.pulse_width_s,
        )
        .context("color-coded video pulse detection failed")?;
        (segments, true)
    };

    let audio_samples = extract_audio_samples(capture_path)?;
    let audio_segments = if options.event_width_codes.is_empty() {
        detect_audio_segments(
            &audio_samples,
            DEFAULT_AUDIO_SAMPLE_RATE_HZ,
            options.audio_window_ms,
        )?
    } else {
        detect_coded_audio_segments(
            &audio_samples,
            DEFAULT_AUDIO_SAMPLE_RATE_HZ,
            options.audio_window_ms,
            &options.event_width_codes,
            options.pulse_width_s,
        )?
    };

    let video_segments = filter_segments_to_analysis_window(video_segments, options, "video")?;
    let audio_segments = filter_segments_to_analysis_window(audio_segments, options, "audio")?;

    if !coded_video_events {
        correlate_segments(
            &video_segments,
            &audio_segments,
            options.pulse_period_s,
            options.pulse_width_s,
            options.marker_tick_period,
            options.max_pair_gap_s,
        )
    } else {
        correlate_coded_segments(
            &video_segments,
            &audio_segments,
            options.pulse_period_s,
            options.pulse_width_s,
            &options.event_width_codes,
            options.max_pair_gap_s,
        )
    }
}

/// Keeps `filter_segments_to_analysis_window` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn filter_segments_to_analysis_window(
    segments: Vec<PulseSegment>,
    options: &SyncAnalysisOptions,
    stream_name: &str,
) -> Result<Vec<PulseSegment>> {
    let start_s = options.analysis_start_s;
    let end_s = options.analysis_end_s;
    if start_s.is_none() && end_s.is_none() {
        return Ok(segments);
    }
    if let Some(start_s) = start_s
        && (!start_s.is_finite() || start_s < 0.0)
    {
        bail!("{stream_name} analysis start must be a finite non-negative timestamp");
    }
    if let Some(end_s) = end_s
        && (!end_s.is_finite() || end_s < 0.0)
    {
        bail!("{stream_name} analysis end must be a finite non-negative timestamp");
    }
    if let (Some(start_s), Some(end_s)) = (start_s, end_s)
        && end_s <= start_s
    {
        bail!("{stream_name} analysis window end must be after its start");
    }

    let filtered = segments
        .into_iter()
        .filter(|segment| {
            start_s.is_none_or(|start_s| segment.start_s >= start_s)
                && end_s.is_none_or(|end_s| segment.start_s <= end_s)
        })
        .collect::<Vec<_>>();
    if filtered.is_empty() {
        bail!("{stream_name} analysis window removed all detected sync pulses");
    }
    Ok(filtered)
}

/// Keeps `reconcile_video_timestamps` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
/// Inputs are the typed parameters; output is the return value or side effect.
fn reconcile_video_timestamps(timestamps: Vec<f64>, frame_count: usize) -> Result<Vec<f64>> {
    if frame_count == 0 {
        bail!("capture did not contain any decoded video brightness frames");
    }
    if timestamps.len() == frame_count {
        return Ok(timestamps);
    }

    let first = timestamps.first().copied();
    let last = timestamps.last().copied();
    if let (Some(first), Some(last)) = (first, last)
        && frame_count > 1
        && last > first
    {
        let step = (last - first) / (frame_count - 1) as f64;
        return Ok((0..frame_count)
            .map(|index| first + index as f64 * step)
            .collect());
    }

    if timestamps.len() > frame_count {
        return Ok(timestamps.into_iter().take(frame_count).collect());
    }

    bail!(
        "ffprobe returned {} video timestamps for {frame_count} decoded brightness frames and no usable duration",
        timestamps.len()
    )
}

#[cfg(test)]
mod tests {
    use super::test_support::{
        audio_samples_to_bytes, click_track_samples, frame_json, thumbnail_rgb_video_bytes,
        thumbnail_video_bytes, with_fake_media_tools,
    };
    use super::{PulseSegment, SyncAnalysisOptions, analyze_capture};
    use crate::sync_probe::analyze::reconcile_video_timestamps;

    #[test]
    /// Keeps `analyze_capture_runs_against_fake_media_tools` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
    /// Inputs are the typed parameters; output is the return value or side effect.
    fn analyze_capture_runs_against_fake_media_tools() {
        let timestamps = (0..15).map(|index| index as f64 / 10.0).collect::<Vec<_>>();
        let brightness = timestamps
            .iter()
            .enumerate()
            .map(|(index, _)| if matches!(index, 0 | 5 | 10) { 250 } else { 5 })
            .collect::<Vec<_>>();
        let audio = click_track_samples(&[0.05, 0.55, 1.05], 53_000);

        with_fake_media_tools(
            &frame_json(&timestamps),
            &thumbnail_video_bytes(&brightness),
            &audio_samples_to_bytes(&audio),
            |capture_path| {
                let report = analyze_capture(
                    capture_path,
                    &SyncAnalysisOptions {
                        pulse_period_s: 0.5,
                        ..SyncAnalysisOptions::default()
                    },
                )
                .expect("analysis report");
                assert_eq!(report.video_event_count, 3);
                assert_eq!(report.audio_event_count, 3);
                assert_eq!(report.paired_event_count, 3);
                assert_eq!(report.skews_ms.len(), 3);
                assert!((report.first_skew_ms - 50.0).abs() < 10.0);
                assert!(report.max_abs_skew_ms < 120.0);
            },
        );
    }

    #[test]
    /// Keeps `analyze_capture_synthesizes_timestamps_when_mjpeg_metadata_overreports_frames` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
    /// Inputs are the typed parameters; output is the return value or side effect.
    fn analyze_capture_synthesizes_timestamps_when_mjpeg_metadata_overreports_frames() {
        let metadata_timestamps = (0..301)
            .map(|index| index as f64 * 0.004)
            .collect::<Vec<_>>();
        let brightness = (0..25)
            .map(|index| if matches!(index, 0 | 10 | 20) { 250 } else { 5 })
            .collect::<Vec<_>>();
        let audio = click_track_samples(&[0.05, 0.55, 1.05], 67_000);

        with_fake_media_tools(
            &frame_json(&metadata_timestamps),
            &thumbnail_video_bytes(&brightness),
            &audio_samples_to_bytes(&audio),
            |capture_path| {
                let report = analyze_capture(
                    capture_path,
                    &SyncAnalysisOptions {
                        pulse_period_s: 0.5,
                        ..SyncAnalysisOptions::default()
                    },
                )
                .expect("analysis report");
                assert_eq!(report.video_event_count, 3);
                assert_eq!(report.audio_event_count, 3);
                assert_eq!(report.paired_event_count, 3);
                assert!(report.max_abs_skew_ms < 120.0);
            },
        );
    }

    #[test]
    /// Keeps `analyze_capture_uses_color_codes_for_mirrored_video_events` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
    /// Inputs are the typed parameters; output is the return value or side effect.
    fn analyze_capture_uses_color_codes_for_mirrored_video_events() {
        let timestamps = (0..45).map(|index| index as f64 * 0.1).collect::<Vec<_>>();
        let colors = timestamps
            .iter()
            .enumerate()
            .map(|(index, _)| match index {
                10 | 11 => (255, 45, 45),
                20 | 21 => (0, 230, 118),
                30 | 31 => (41, 121, 255),
                _ => (0, 0, 0),
            })
            .collect::<Vec<_>>();
        let mut audio = vec![0i16; 220_000];
        for (start_s, frequency_hz) in [(1.05, 620.0), (2.05, 780.0), (3.05, 940.0)] {
            add_sine(&mut audio, 48_000, start_s, 0.12, frequency_hz, 18_000.0);
        }

        with_fake_media_tools(
            &frame_json(&timestamps),
            &thumbnail_rgb_video_bytes(&colors),
            &audio_samples_to_bytes(&audio),
            |capture_path| {
                let report = analyze_capture(
                    capture_path,
                    &SyncAnalysisOptions {
                        pulse_period_s: 1.0,
                        event_width_codes: vec![1, 2, 3],
                        ..SyncAnalysisOptions::default()
                    },
                )
                .expect("analysis report");
                assert_eq!(report.video_event_count, 3);
                assert_eq!(report.paired_event_count, 3);
                assert!(report.max_abs_skew_ms < 120.0);
            },
        );
    }

    #[test]
    fn reconcile_video_timestamps_resamples_metadata_span_to_decoded_frame_count() {
        let reconciled = reconcile_video_timestamps(vec![0.0, 0.004, 0.008, 1.0], 3)
            .expect("reconciled timestamps");
        assert_eq!(reconciled, vec![0.0, 0.5, 1.0]);
    }

    #[test]
    /// Keeps `analysis_window_validation_and_timestamp_fallbacks_are_explicit` explicit because bad probe windows should fail loudly instead of silently removing the evidence needed for sync decisions.
    /// Inputs are synthetic pulse segments plus timestamp metadata shapes; output proves filtering and reconciliation keep their diagnostic branches stable.
    fn analysis_window_validation_and_timestamp_fallbacks_are_explicit() {
        let segments = vec![
            PulseSegment {
                start_s: 1.0,
                end_s: 1.1,
                duration_s: 0.1,
            },
            PulseSegment {
                start_s: 2.0,
                end_s: 2.1,
                duration_s: 0.1,
            },
        ];

        assert_eq!(
            super::filter_segments_to_analysis_window(
                segments.clone(),
                &SyncAnalysisOptions::default(),
                "video",
            )
            .expect("unbounded window"),
            segments
        );
        assert!(
            super::filter_segments_to_analysis_window(
                segments.clone(),
                &SyncAnalysisOptions {
                    analysis_start_s: Some(f64::NAN),
                    ..SyncAnalysisOptions::default()
                },
                "video",
            )
            .is_err()
        );
        assert!(
            super::filter_segments_to_analysis_window(
                segments.clone(),
                &SyncAnalysisOptions {
                    analysis_end_s: Some(-1.0),
                    ..SyncAnalysisOptions::default()
                },
                "audio",
            )
            .is_err()
        );
        assert!(
            super::filter_segments_to_analysis_window(
                segments.clone(),
                &SyncAnalysisOptions {
                    analysis_start_s: Some(3.0),
                    analysis_end_s: Some(2.0),
                    ..SyncAnalysisOptions::default()
                },
                "video",
            )
            .is_err()
        );
        assert!(
            super::filter_segments_to_analysis_window(
                segments,
                &SyncAnalysisOptions {
                    analysis_start_s: Some(10.0),
                    ..SyncAnalysisOptions::default()
                },
                "audio",
            )
            .is_err()
        );

        assert!(reconcile_video_timestamps(vec![0.0], 0).is_err());
        assert_eq!(
            reconcile_video_timestamps(vec![0.0, 0.5, 1.0], 1).expect("truncated timestamps"),
            vec![0.0]
        );
        assert!(reconcile_video_timestamps(vec![0.0], 2).is_err());
    }

    /// Keeps `add_sine` explicit because it sits on sync-probe analysis, where small timestamp or pairing mistakes can hide real A/V skew.
    /// Inputs are the typed parameters; output is the return value or side effect.
    fn add_sine(
        samples: &mut [i16],
        sample_rate_hz: u32,
        start_s: f64,
        duration_s: f64,
        frequency_hz: f64,
        amplitude: f64,
    ) {
        let start = (start_s * f64::from(sample_rate_hz)).round() as usize;
        let len = (duration_s * f64::from(sample_rate_hz)).round() as usize;
        for (offset, sample) in samples.iter_mut().skip(start).take(len).enumerate() {
            let t = offset as f64 / f64::from(sample_rate_hz);
            let value = amplitude * (2.0 * std::f64::consts::PI * frequency_hz * t).sin();
            *sample = value
                .round()
                .clamp(f64::from(i16::MIN), f64::from(i16::MAX)) as i16;
        }
    }
}
