//! Integration tests for ff-filter push/pull frame processing.
//!
//! # Temporary file cleanup
//!
//! These tests are read-only with respect to the filesystem: they push frames
//! through in-memory filter graphs and inspect the resulting `VideoFrame` /
//! `AudioFrame` values. No output files are written, so no
//! `fixtures/mod.rs` with `FileGuard`/`DirGuard` is needed.

#![allow(clippy::unwrap_used)]

use ff_filter::{
    DrawTextOptions, FilterError, FilterGraph, HwAccel, Rgb, ScaleAlgorithm, ToneMap,
    XfadeTransition, YadifMode,
};
use ff_format::{AudioFrame, PixelFormat, PooledBuffer, SampleFormat, Timestamp, VideoFrame};

/// 64×64 Yuv420p frame filled with grey (Y=128, U=128, V=128).
fn make_yuv420p_frame(width: u32, height: u32) -> VideoFrame {
    let y = vec![128u8; (width * height) as usize];
    let u = vec![128u8; ((width / 2) * (height / 2)) as usize];
    let v = vec![128u8; ((width / 2) * (height / 2)) as usize];
    VideoFrame::new(
        vec![
            PooledBuffer::standalone(y),
            PooledBuffer::standalone(u),
            PooledBuffer::standalone(v),
        ],
        vec![width as usize, (width / 2) as usize, (width / 2) as usize],
        width,
        height,
        PixelFormat::Yuv420p,
        Timestamp::default(),
        true,
    )
    .unwrap()
}

/// Stereo packed F32 audio frame, 1024 samples @ 48 kHz.
fn make_audio_frame() -> AudioFrame {
    AudioFrame::empty(1024, 2, 48000, SampleFormat::F32).unwrap()
}

#[test]
fn pull_video_before_push_should_return_none() {
    let mut graph = match FilterGraph::builder()
        .scale(32, 32, ScaleAlgorithm::Fast)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let result = graph
        .pull_video()
        .expect("pull_video must not fail before any push");
    assert!(
        result.is_none(),
        "expected None before any push, got Some(frame)"
    );
}

#[test]
fn push_video_and_pull_through_scale_should_return_resized_frame() {
    let mut graph = match FilterGraph::builder()
        .scale(32, 32, ScaleAlgorithm::Fast)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after scale push");
    assert_eq!(out.width(), 32, "width should be scaled to 32");
    assert_eq!(out.height(), 32, "height should be scaled to 32");
}

#[test]
fn push_video_to_invalid_slot_should_return_error() {
    let mut graph = match FilterGraph::builder()
        .scale(32, 32, ScaleAlgorithm::Fast)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    // Push to slot 0 first to ensure the graph is fully initialised.
    // On some Linux FFmpeg builds the filter registry is not populated until
    // the first AVFilterGraph is allocated; without this step,
    // ensure_video_graph can return BuildFailed before the slot check runs.
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.push_video(1, &frame);
    assert!(
        matches!(result, Err(FilterError::InvalidInput { slot: 1, .. })),
        "expected InvalidInput for slot 1, got {result:?}"
    );
}

#[test]
fn pull_audio_before_push_should_return_none() {
    let mut graph = match FilterGraph::builder().volume(0.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let result = graph
        .pull_audio()
        .expect("pull_audio must not fail before any push");
    assert!(
        result.is_none(),
        "expected None before any push, got Some(frame)"
    );
}

#[test]
fn push_audio_and_pull_through_volume_should_return_frame() {
    let mut graph = match FilterGraph::builder().volume(0.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after volume push");
    assert_eq!(out.sample_rate(), 48000);
    assert_eq!(out.channels(), 2);
    assert_eq!(out.samples(), 1024);
}

#[test]
fn push_video_through_trim_should_return_frame_within_range() {
    let mut graph = match FilterGraph::builder().trim(0.0, 5.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after trim push within range");
    assert_eq!(out.width(), 64, "width should be unchanged after trim");
    assert_eq!(out.height(), 64, "height should be unchanged after trim");
}

#[test]
fn push_video_through_trim_frame_timestamp_should_be_within_trim_range() {
    // Push a frame at t=0 through a [0, 5) trim window and confirm the output
    // timestamp lies within the window.
    let mut graph = match FilterGraph::builder().trim(0.0, 5.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after trim push within range");
    let secs = out.timestamp().as_secs_f64();
    assert!(
        secs >= 0.0 && secs < 5.0,
        "frame timestamp {secs:.3}s must fall within the [0, 5) trim window"
    );
}

#[test]
fn push_video_through_crop_should_return_cropped_frame() {
    let mut graph = match FilterGraph::builder().crop(0, 0, 32, 32).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after crop push");
    assert_eq!(out.width(), 32, "width should be cropped to 32");
    assert_eq!(out.height(), 32, "height should be cropped to 32");
}

#[test]
fn push_1080p_frame_through_crop_100_100_1720_880_should_return_cropped_frame() {
    // Crop a 1920×1080 frame to a 1720×880 rectangle starting at (100, 100).
    let mut graph = match FilterGraph::builder().crop(100, 100, 1720, 880).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(1920, 1080);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after crop push");
    assert_eq!(out.width(), 1720, "width should be 1720 after crop");
    assert_eq!(out.height(), 880, "height should be 880 after crop");
}

#[test]
fn push_video_through_overlay_should_return_composited_frame() {
    let mut graph = match FilterGraph::builder().overlay(0, 0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let base = make_yuv420p_frame(64, 64);
    let overlay = make_yuv420p_frame(64, 64);
    // Push base video to slot 0 first; this also initialises the graph.
    match graph.push_video(0, &base) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    // Push overlay video to slot 1.
    match graph.push_video(1, &overlay) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after overlay push");
    assert_eq!(out.width(), 64, "output width should match base video");
    assert_eq!(out.height(), 64, "output height should match base video");
}

#[test]
fn push_video_through_fade_in_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().fade_in(0.0, 1.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after fade_in push");
    assert_eq!(out.width(), 64, "width should be unchanged after fade_in");
    assert_eq!(out.height(), 64, "height should be unchanged after fade_in");
}

#[test]
fn push_video_through_fade_out_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().fade_out(0.0, 1.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after fade_out push");
    assert_eq!(out.width(), 64, "width should be unchanged after fade_out");
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after fade_out"
    );
}

#[test]
fn push_video_through_rotate_should_return_frame() {
    let mut graph = match FilterGraph::builder().rotate(90.0, "black").build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    // Use a square frame so output dimensions are unambiguous after 90° rotation.
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    assert!(result.is_some(), "expected Some(frame) after rotate push");
}

#[test]
fn push_video_through_tone_map_should_return_frame() {
    // The tonemap filter is HDR-specific and may not be available in all
    // FFmpeg builds, or may reject non-HDR input. All failure paths are
    // treated as graceful skips.
    let mut graph = match FilterGraph::builder().tone_map(ToneMap::Hable).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    assert!(result.is_some(), "expected Some(frame) after tone_map push");
}

#[test]
fn push_audio_through_amix_should_return_mixed_frame() {
    let mut graph = match FilterGraph::builder().amix(2).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    // Push to slot 0 — this also initialises the audio graph.
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    // Push to slot 1 so amix has data on both inputs and can produce output.
    match graph.push_audio(1, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after amix push to both slots");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
    assert_eq!(out.channels(), 2, "channel count should be unchanged");
}

#[test]
fn push_audio_through_equalizer_should_return_frame_with_same_properties() {
    let mut graph = match FilterGraph::builder()
        .equalizer(vec![ff_filter::EqBand::Peak {
            freq_hz: 1000.0,
            gain_db: 3.0,
            q: 1.0,
        }])
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after equalizer push");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
    assert_eq!(out.channels(), 2, "channel count should be unchanged");
    assert_eq!(out.samples(), 1024, "sample count should be unchanged");
}

#[test]
fn push_video_through_cuda_scale_should_return_resized_frame_or_skip() {
    // Build a filter graph with CUDA hardware acceleration and a scale step.
    // If CUDA is not available (av_hwdevice_ctx_create fails, hwupload_cuda
    // filter is missing, or avfilter_graph_config rejects the chain), all
    // error paths are treated as graceful skips.
    let mut graph = match FilterGraph::builder()
        .hardware(HwAccel::Cuda)
        .scale(32, 32, ScaleAlgorithm::Fast)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping (build): {e}");
            return;
        }
    };

    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping (push): {e}");
            return;
        }
    }

    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after cuda scale push");
    assert_eq!(out.width(), 32, "width should be scaled to 32");
    assert_eq!(out.height(), 32, "height should be scaled to 32");
}

#[test]
fn push_video_through_videotoolbox_scale_should_return_resized_frame_or_skip() {
    // VideoToolbox is macOS-only; on other platforms av_hwdevice_ctx_create
    // will fail and the test skips gracefully.  On macOS the hwupload filter
    // uploads frames to the VideoToolbox device and hwdownload brings them
    // back; avfilter_graph_config failure is also treated as a skip.
    let mut graph = match FilterGraph::builder()
        .hardware(HwAccel::VideoToolbox)
        .scale(32, 32, ScaleAlgorithm::Fast)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping (build): {e}");
            return;
        }
    };

    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping (push): {e}");
            return;
        }
    }

    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after videotoolbox scale push");
    assert_eq!(out.width(), 32, "width should be scaled to 32");
    assert_eq!(out.height(), 32, "height should be scaled to 32");
}

#[test]
fn push_video_through_vaapi_scale_should_return_resized_frame_or_skip() {
    // VAAPI is Linux-only; on other platforms (or Linux without a VA-API
    // device) av_hwdevice_ctx_create will fail and the test skips gracefully.
    // On a VA-API-capable Linux system the hwupload filter uploads frames to
    // the VAAPI device and hwdownload brings them back.
    let mut graph = match FilterGraph::builder()
        .hardware(HwAccel::Vaapi)
        .scale(32, 32, ScaleAlgorithm::Fast)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping (build): {e}");
            return;
        }
    };

    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping (push): {e}");
            return;
        }
    }

    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after vaapi scale push");
    assert_eq!(out.width(), 32, "width should be scaled to 32");
    assert_eq!(out.height(), 32, "height should be scaled to 32");
}

#[test]
fn push_video_through_eq_saturation_zero_should_return_frame_with_same_dimensions() {
    // saturation=0.0 converts to grayscale; frame dimensions are preserved.
    let mut graph = match FilterGraph::builder().eq(0.0, 1.0, 0.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after eq push");
    assert_eq!(out.width(), 64, "width should be unchanged after eq");
    assert_eq!(out.height(), 64, "height should be unchanged after eq");
}

#[test]
fn push_video_through_curves_s_curve_should_return_frame_with_same_dimensions() {
    // Apply an S-curve to the master channel to boost midtone contrast;
    // frame dimensions must be preserved.
    let s_curve = vec![
        (0.0, 0.0),
        (0.25, 0.15),
        (0.5, 0.5),
        (0.75, 0.85),
        (1.0, 1.0),
    ];
    let mut graph = match FilterGraph::builder()
        .curves(s_curve, vec![], vec![], vec![])
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after curves push");
    assert_eq!(out.width(), 64, "width should be unchanged after curves");
    assert_eq!(out.height(), 64, "height should be unchanged after curves");
}

#[test]
fn push_video_through_white_balance_should_return_frame_with_same_dimensions() {
    // Apply a warm (3200 K) white balance correction; frame dimensions must be preserved.
    let mut graph = match FilterGraph::builder().white_balance(3200, 0.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after white_balance push");
    assert_eq!(
        out.width(),
        64,
        "width should be unchanged after white_balance"
    );
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after white_balance"
    );
}

#[test]
fn push_video_through_hue_180_should_return_frame_with_same_dimensions() {
    // Rotating hue by 180° inverts the hue; frame dimensions must be preserved.
    let mut graph = match FilterGraph::builder().hue(180.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after hue push");
    assert_eq!(
        out.width(),
        64,
        "width should be unchanged after hue rotation"
    );
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after hue rotation"
    );
}

#[test]
fn push_video_through_gamma_should_return_frame_with_same_dimensions() {
    // Apply 2.2 gamma to all channels (brightens midtones); dimensions must be preserved.
    let mut graph = match FilterGraph::builder().gamma(2.2, 2.2, 2.2).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after gamma push");
    assert_eq!(out.width(), 64, "width should be unchanged after gamma");
    assert_eq!(out.height(), 64, "height should be unchanged after gamma");
}

#[test]
fn push_video_through_three_way_cc_neutral_should_return_frame_with_same_dimensions() {
    // Neutral lift/gamma/gain (all 1.0) is an identity operation; dimensions must be preserved.
    let mut graph = match FilterGraph::builder()
        .three_way_cc(Rgb::NEUTRAL, Rgb::NEUTRAL, Rgb::NEUTRAL)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after three_way_cc push");
    assert_eq!(
        out.width(),
        64,
        "width should be unchanged after three_way_cc"
    );
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after three_way_cc"
    );
}

#[test]
fn push_video_through_vignette_should_return_frame_with_same_dimensions() {
    // Default-angle vignette centred on the frame; dimensions must be preserved.
    let mut graph = match FilterGraph::builder()
        .vignette(std::f32::consts::PI / 5.0, 0.0, 0.0)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after vignette push");
    assert_eq!(out.width(), 64, "width should be unchanged after vignette");
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after vignette"
    );
}

#[test]
fn push_video_through_hflip_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().hflip().build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after hflip push");
    assert_eq!(out.width(), 64, "width should be unchanged after hflip");
    assert_eq!(out.height(), 64, "height should be unchanged after hflip");
}

#[test]
fn push_video_through_vflip_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().vflip().build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after vflip push");
    assert_eq!(out.width(), 64, "width should be unchanged after vflip");
    assert_eq!(out.height(), 64, "height should be unchanged after vflip");
}

#[test]
fn push_720p_frame_through_pad_1920_1080_centred_should_return_1080p_frame() {
    let mut graph = match FilterGraph::builder()
        .pad(1920, 1080, -1, -1, "black")
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(1280, 720);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after pad push");
    assert_eq!(out.width(), 1920, "width should be padded to 1920");
    assert_eq!(out.height(), 1080, "height should be padded to 1080");
}

#[test]
fn push_4x3_frame_through_fit_to_aspect_16x9_should_return_target_dimensions() {
    // 64×48 is 4:3; fitting into 128×72 (16:9) should produce pillarbox bars.
    // Scale factor = min(128/64, 72/48) = min(2.0, 1.5) = 1.5
    // Scaled: 64×1.5=96, 48×1.5=72 → pad to 128×72 with (128-96)/2=16px bars each side.
    let mut graph = match FilterGraph::builder()
        .fit_to_aspect(128, 72, "black")
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 48);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after fit_to_aspect push");
    assert_eq!(
        out.width(),
        128,
        "width should match target after fit_to_aspect"
    );
    assert_eq!(
        out.height(),
        72,
        "height should match target after fit_to_aspect"
    );
}

#[test]
fn push_wide_frame_through_fit_to_aspect_should_produce_letterbox() {
    // 128×54 is ~2.37:1; fitting into 128×72 (16:9) should produce letterbox bars.
    // Scale factor = min(128/128, 72/54) = min(1.0, 1.33) = 1.0 (no scale needed)
    // Pad 128×72 with (72-54)/2=9px bars top and bottom.
    let mut graph = match FilterGraph::builder()
        .fit_to_aspect(128, 72, "black")
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(128, 54);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after fit_to_aspect letterbox push");
    assert_eq!(
        out.width(),
        128,
        "width should match target after letterbox fit"
    );
    assert_eq!(
        out.height(),
        72,
        "height should match target after letterbox fit"
    );
}

#[test]
fn push_video_through_gblur_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().gblur(5.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after gblur push");
    assert_eq!(out.width(), 64, "width should be unchanged after gblur");
    assert_eq!(out.height(), 64, "height should be unchanged after gblur");
}

#[test]
fn push_video_through_unsharp_sharpen_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().unsharp(1.0, 0.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after unsharp push");
    assert_eq!(out.width(), 64, "width should be unchanged after unsharp");
    assert_eq!(out.height(), 64, "height should be unchanged after unsharp");
}

#[test]
fn push_video_through_hqdn3d_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().hqdn3d(4.0, 3.0, 6.0, 4.5).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after hqdn3d push");
    assert_eq!(out.width(), 64, "width should be unchanged after hqdn3d");
    assert_eq!(out.height(), 64, "height should be unchanged after hqdn3d");
}

#[test]
fn push_video_through_nlmeans_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().nlmeans(8.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after nlmeans push");
    assert_eq!(out.width(), 64, "width should be unchanged after nlmeans");
    assert_eq!(out.height(), 64, "height should be unchanged after nlmeans");
}

#[test]
fn push_video_through_yadif_frame_mode_should_accept_frames_without_error() {
    // yadif is a temporal filter; it buffers frames before emitting output.
    // This test verifies that frames are accepted and pull_video does not error.
    // A single progressive frame may not produce output (None is acceptable).
    let mut graph = match FilterGraph::builder().yadif(YadifMode::Frame).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    // pull_video must not error; None is acceptable (frame buffered by yadif).
    let result = graph.pull_video().expect("pull_video must not fail");
    if let Some(out) = result {
        assert_eq!(out.width(), 64, "width should be unchanged after yadif");
        assert_eq!(out.height(), 64, "height should be unchanged after yadif");
    }
}

#[test]
fn push_video_through_fade_in_white_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().fade_in_white(0.0, 1.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after fade_in_white push");
    assert_eq!(
        out.width(),
        64,
        "width should be unchanged after fade_in_white"
    );
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after fade_in_white"
    );
}

#[test]
fn push_video_through_fade_out_white_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder().fade_out_white(0.0, 1.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after fade_out_white push");
    assert_eq!(
        out.width(),
        64,
        "width should be unchanged after fade_out_white"
    );
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after fade_out_white"
    );
}

#[test]
fn push_two_clips_through_xfade_dissolve_should_return_frame_with_same_dimensions() {
    let mut graph = match FilterGraph::builder()
        .xfade(XfadeTransition::Dissolve, 1.0, 4.0)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let clip_a = make_yuv420p_frame(64, 64);
    let clip_b = make_yuv420p_frame(64, 64);
    // Push clip A to slot 0 first; this initialises the graph.
    match graph.push_video(0, &clip_a) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    // Push clip B to slot 1.
    match graph.push_video(1, &clip_b) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after xfade push");
    assert_eq!(
        out.width(),
        64,
        "output width should match input after xfade"
    );
    assert_eq!(
        out.height(),
        64,
        "output height should match input after xfade"
    );
}

#[test]
fn push_video_through_drawtext_should_return_frame_with_same_dimensions() {
    let opts = DrawTextOptions {
        text: "Hello".to_string(),
        x: "10".to_string(),
        y: "10".to_string(),
        font_size: 24,
        font_color: "white".to_string(),
        font_file: None,
        opacity: 1.0,
        box_color: None,
        box_border_width: 0,
    };
    let mut graph = match FilterGraph::builder().drawtext(opts).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after drawtext push");
    assert_eq!(out.width(), 64, "width should be unchanged after drawtext");
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after drawtext"
    );
}

#[test]
fn push_video_through_drawtext_with_box_should_return_frame_with_same_dimensions() {
    let opts = DrawTextOptions {
        text: "Hello".to_string(),
        x: "10".to_string(),
        y: "10".to_string(),
        font_size: 24,
        font_color: "white".to_string(),
        font_file: None,
        opacity: 1.0,
        box_color: Some("black@0.5".to_string()),
        box_border_width: 5,
    };
    let mut graph = match FilterGraph::builder().drawtext(opts).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after drawtext with box push");
    assert_eq!(
        out.width(),
        64,
        "width should be unchanged after drawtext with box"
    );
    assert_eq!(
        out.height(),
        64,
        "height should be unchanged after drawtext with box"
    );
}

#[test]
fn push_audio_through_agate_should_return_frame_with_same_properties() {
    let mut graph = match FilterGraph::builder().agate(-40.0, 10.0, 100.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after agate push");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
    assert_eq!(out.channels(), 2, "channel count should be unchanged");
    assert_eq!(out.samples(), 1024, "sample count should be unchanged");
}

#[test]
fn push_audio_through_compressor_should_return_frame_with_same_properties() {
    let mut graph = match FilterGraph::builder()
        .compressor(-20.0, 4.0, 10.0, 100.0, 6.0)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after compressor push");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
    assert_eq!(out.channels(), 2, "channel count should be unchanged");
    assert_eq!(out.samples(), 1024, "sample count should be unchanged");
}

#[test]
fn push_stereo_audio_through_stereo_to_mono_should_return_mono_frame() {
    let mut graph = match FilterGraph::builder().stereo_to_mono().build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after stereo_to_mono push");
    assert_eq!(out.channels(), 1, "output should be mono (1 channel)");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
}

#[test]
fn push_audio_through_channel_map_should_return_frame_with_same_properties() {
    // "FL|FR" maps the two stereo channels to the same layout (identity remap).
    let mut graph = match FilterGraph::builder().channel_map("FL|FR").build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after channel_map push");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
    assert_eq!(
        out.channels(),
        2,
        "channel count should be unchanged for identity remap"
    );
    assert_eq!(out.samples(), 1024, "sample count should be unchanged");
}

#[test]
fn push_audio_through_audio_delay_positive_should_return_frame_with_same_properties() {
    let mut graph = match FilterGraph::builder().audio_delay(100.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    // adelay pads with silence first; result may be None if the delay
    // exceeds one frame's worth of samples.  No error is the key assertion.
    let result = graph.pull_audio().expect("pull_audio must not fail");
    if let Some(out) = result {
        assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
        assert_eq!(out.channels(), 2, "channel count should be unchanged");
    }
}

#[test]
fn push_audio_through_audio_delay_negative_should_not_error() {
    // Use a 5 ms advance. The test frame is 1024/48000 ≈ 21 ms, so atrim
    // trims only a portion of it and may still return a frame.
    // Regardless of whether a frame is returned, no error is the key assertion.
    let mut graph = match FilterGraph::builder().audio_delay(-5.0).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    if let Some(out) = result {
        assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
        assert_eq!(out.channels(), 2, "channel count should be unchanged");
    }
}

#[test]
fn push_video_through_concat_video_should_produce_output() {
    let mut graph = match FilterGraph::builder().concat_video(2).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_yuv420p_frame(64, 64);
    match graph.push_video(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    match graph.push_video(1, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after concat push to both slots");
    assert_eq!(out.width(), 64, "width should be unchanged");
    assert_eq!(out.height(), 64, "height should be unchanged");
}

#[test]
fn push_audio_through_concat_audio_should_produce_output() {
    let mut graph = match FilterGraph::builder().concat_audio(2).build() {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let frame = make_audio_frame();
    match graph.push_audio(0, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    match graph.push_audio(1, &frame) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_audio().expect("pull_audio must not fail");
    let out = result.expect("expected Some(frame) after concat push to both slots");
    assert_eq!(out.sample_rate(), 48000, "sample rate should be unchanged");
    assert_eq!(out.channels(), 2, "channel count should be unchanged");
}

#[test]
fn push_two_clips_through_join_with_dissolve_should_produce_output() {
    let mut graph = match FilterGraph::builder()
        .join_with_dissolve(4.0, 1.0, 1.0)
        .build()
    {
        Ok(g) => g,
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    };
    let clip_a = make_yuv420p_frame(64, 64);
    let clip_b = make_yuv420p_frame(64, 64);
    // Push clip A to slot 0 first; this initialises the graph.
    match graph.push_video(0, &clip_a) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    // Push clip B to slot 1.
    match graph.push_video(1, &clip_b) {
        Ok(()) => {}
        Err(e) => {
            println!("Skipping: {e}");
            return;
        }
    }
    let result = graph.pull_video().expect("pull_video must not fail");
    let out = result.expect("expected Some(frame) after join_with_dissolve push");
    assert_eq!(
        out.width(),
        64,
        "output width should match input after join_with_dissolve"
    );
    assert_eq!(
        out.height(),
        64,
        "output height should match input after join_with_dissolve"
    );
}
