//! Unsafe `FFmpeg` filter-graph builders for multi-track composition.

#![allow(unsafe_code)]
#![allow(unsafe_op_in_unsafe_fn)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_precision_loss)]

use std::path::PathBuf;
use std::ptr::NonNull;
use std::time::Duration;

use ff_format::ChannelLayout;

use crate::animation::{AnimatedValue, AnimationEntry};
use crate::error::FilterError;
use crate::filter_inner::FilterGraphInner;
use crate::graph::graph::FilterGraph;
use crate::graph::types::Rgb;

use super::multi_track_composer::VideoLayer;
use super::multi_track_mixer::AudioTrack;

// ── Video composition graph builder ──────────────────────────────────────────

pub(super) unsafe fn build_video_composition(
    canvas_width: u32,
    canvas_height: u32,
    background: Rgb,
    layers: &[VideoLayer],
) -> Result<FilterGraph, FilterError> {
    use std::ffi::CString;

    macro_rules! bail {
        ($graph:ident, $reason:expr) => {{
            let mut g = $graph;
            ff_sys::avfilter_graph_free(std::ptr::addr_of_mut!(g));
            return Err(FilterError::CompositionFailed {
                reason: format!("{}", $reason),
            });
        }};
    }

    let graph = ff_sys::avfilter_graph_alloc();
    if graph.is_null() {
        return Err(FilterError::CompositionFailed {
            reason: "avfilter_graph_alloc failed".to_string(),
        });
    }

    // ── Base canvas ───────────────────────────────────────────────────────────
    let r = (background.r.clamp(0.0, 1.0) * 255.0) as u8;
    let g_ch = (background.g.clamp(0.0, 1.0) * 255.0) as u8;
    let b = (background.b.clamp(0.0, 1.0) * 255.0) as u8;
    let color_args_str =
        format!("c=#{r:02x}{g_ch:02x}{b:02x}:s={canvas_width}x{canvas_height}:r=30");
    let Ok(color_args) = CString::new(color_args_str.as_str()) else {
        bail!(graph, "CString::new failed for color filter args");
    };
    let color_filter = ff_sys::avfilter_get_by_name(c"color".as_ptr());
    if color_filter.is_null() {
        bail!(graph, "filter not found: color");
    }
    let mut base_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut base_ctx,
        color_filter,
        c"base".as_ptr(),
        color_args.as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create color filter code={ret}"));
    }
    log::debug!(
        "video composition color source canvas={canvas_width}x{canvas_height} \
         color=#{r:02x}{g_ch:02x}{b:02x}"
    );

    let mut prev_ctx = base_ctx;
    let layer_count = layers.len();
    let mut animations: Vec<AnimationEntry> = Vec::new();

    // Pre-compute which layers should skip their overlay because the next
    // layer will cross-fade from them.  skip_overlay[i] is true when
    // layers[i+1] has an in_transition on the same z_order as layers[i].
    let skip_overlay: Vec<bool> = (0..layer_count)
        .map(|i| {
            layers.get(i + 1).is_some_and(|next| {
                next.in_transition.is_some() && next.z_order == layers[i].z_order
            })
        })
        .collect();
    // Saved chain_end from the preceding layer when skip_overlay was true.
    let mut saved_chain: *mut ff_sys::AVFilterContext = std::ptr::null_mut();

    for (idx, layer) in layers.iter().enumerate() {
        // On Windows, paths contain backslashes and a drive-letter colon
        // (e.g. "D:\…").  FFmpeg's filter-option parser uses ":" as a
        // key=value separator, so the colon must be escaped as "\:".
        // Forward-slashes are safe on all platforms.
        let path = layer
            .source
            .to_string_lossy()
            .replace('\\', "/")
            .replace(':', "\\:");
        let is_last = idx == layer_count - 1;

        // ── movie= source ─────────────────────────────────────────────────────
        let movie_filter = ff_sys::avfilter_get_by_name(c"movie".as_ptr());
        if movie_filter.is_null() {
            bail!(graph, "filter not found: movie");
        }
        let Ok(movie_name) = CString::new(format!("movie{idx}")) else {
            bail!(graph, "CString::new failed for movie name");
        };
        let Ok(movie_args) = CString::new(format!("filename={path}")) else {
            bail!(graph, "CString::new failed for movie args");
        };
        let mut movie_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
        let ret = ff_sys::avfilter_graph_create_filter(
            &raw mut movie_ctx,
            movie_filter,
            movie_name.as_ptr(),
            movie_args.as_ptr(),
            std::ptr::null_mut(),
            graph,
        );
        if ret < 0 {
            bail!(
                graph,
                format!("failed to create movie filter layer={idx} code={ret}")
            );
        }
        log::debug!("video composition layer={idx} movie source path={path}");
        let mut chain_end = movie_ctx;

        // ── Optional trim + setpts ────────────────────────────────────────────
        if let (Some(in_pt), Some(out_pt)) = (layer.in_point, layer.out_point) {
            let start = in_pt.as_secs_f64();
            let end = out_pt.as_secs_f64();

            let trim_filter = ff_sys::avfilter_get_by_name(c"trim".as_ptr());
            if trim_filter.is_null() {
                bail!(graph, "filter not found: trim");
            }
            let Ok(trim_name) = CString::new(format!("trim{idx}")) else {
                bail!(graph, "CString::new failed for trim name");
            };
            let Ok(trim_args) = CString::new(format!("start={start}:end={end}")) else {
                bail!(graph, "CString::new failed for trim args");
            };
            let mut trim_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut trim_ctx,
                trim_filter,
                trim_name.as_ptr(),
                trim_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create trim filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, trim_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: movie→trim layer={idx}"));
            }
            chain_end = trim_ctx;

            let setpts_filter = ff_sys::avfilter_get_by_name(c"setpts".as_ptr());
            if setpts_filter.is_null() {
                bail!(graph, "filter not found: setpts");
            }
            let Ok(sp_name) = CString::new(format!("setpts_trim{idx}")) else {
                bail!(graph, "CString::new failed for setpts name");
            };
            let mut sp_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut sp_ctx,
                setpts_filter,
                sp_name.as_ptr(),
                c"PTS-STARTPTS".as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create setpts filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, sp_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: trim→setpts layer={idx}"));
            }
            chain_end = sp_ctx;
        }

        // ── Optional timeline offset ──────────────────────────────────────────
        if layer.time_offset > Duration::ZERO {
            let offset = layer.time_offset.as_secs_f64();
            let setpts_filter = ff_sys::avfilter_get_by_name(c"setpts".as_ptr());
            if setpts_filter.is_null() {
                bail!(graph, "filter not found: setpts");
            }
            let Ok(sp_name) = CString::new(format!("setpts_offset{idx}")) else {
                bail!(graph, "CString::new failed for setpts offset name");
            };
            let Ok(sp_args) = CString::new(format!("PTS+{offset}/TB")) else {
                bail!(graph, "CString::new failed for setpts offset args");
            };
            let mut sp_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut sp_ctx,
                setpts_filter,
                sp_name.as_ptr(),
                sp_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create setpts offset filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, sp_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →setpts_offset layer={idx}"));
            }
            chain_end = sp_ctx;
        }

        // ── Optional scale ────────────────────────────────────────────────────
        let sx = layer.scale_x.value_at(Duration::ZERO);
        let sy = layer.scale_y.value_at(Duration::ZERO);
        if (sx - 1.0).abs() > f64::EPSILON || (sy - 1.0).abs() > f64::EPSILON {
            let sw = (f64::from(canvas_width) * sx).round() as u32;
            let sh = (f64::from(canvas_height) * sy).round() as u32;
            let scale_filter = ff_sys::avfilter_get_by_name(c"scale".as_ptr());
            if scale_filter.is_null() {
                bail!(graph, "filter not found: scale");
            }
            let Ok(sc_name) = CString::new(format!("scale{idx}")) else {
                bail!(graph, "CString::new failed for scale name");
            };
            let Ok(sc_args) = CString::new(format!("{sw}:{sh}")) else {
                bail!(graph, "CString::new failed for scale args");
            };
            let mut sc_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut sc_ctx,
                scale_filter,
                sc_name.as_ptr(),
                sc_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create scale filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, sc_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →scale layer={idx}"));
            }
            chain_end = sc_ctx;
        }

        // ── Optional rotation ─────────────────────────────────────────────────
        let rotation_deg = layer.rotation.value_at(Duration::ZERO);
        if rotation_deg.abs() > f64::EPSILON {
            let angle_rad = rotation_deg.to_radians();
            let rotate_filter = ff_sys::avfilter_get_by_name(c"rotate".as_ptr());
            if rotate_filter.is_null() {
                bail!(graph, "filter not found: rotate");
            }
            let Ok(rot_name) = CString::new(format!("layer_{idx}_rotate")) else {
                bail!(graph, "CString::new failed for rotate name");
            };
            let Ok(rot_args) = CString::new(format!("{angle_rad}")) else {
                bail!(graph, "CString::new failed for rotate args");
            };
            let mut rot_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut rot_ctx,
                rotate_filter,
                rot_name.as_ptr(),
                rot_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create rotate filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, rot_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →rotate layer={idx}"));
            }
            chain_end = rot_ctx;
            log::debug!("video composition layer={idx} rotate angle_deg={rotation_deg}");
        }

        // ── Optional opacity ──────────────────────────────────────────────────
        //
        // Animated opacity: add `format=yuva420p` → `colorchannelmixer aa=<v>`
        // so that `tick()` can update the alpha plane per-frame via send_command.
        // The overlay filter must use `format=auto` to blend the alpha channel.
        //
        // Static opacity < 1.0: legacy path (colorchannelmixer only, no alpha
        // conversion).  The overlay still receives a yuv420p frame which FFmpeg
        // handles by treating the fully-opaque layer as semi-transparent via the
        // colorchannelmixer alpha reduction.
        let is_animated_opacity = matches!(layer.opacity, AnimatedValue::Track(_));
        let opacity_initial = layer.opacity.value_at(Duration::ZERO).clamp(0.0, 1.0);

        if is_animated_opacity {
            // ── format=yuva420p: ensure the alpha plane exists ─────────────────
            let fmt_filter = ff_sys::avfilter_get_by_name(c"format".as_ptr());
            if fmt_filter.is_null() {
                bail!(graph, "filter not found: format");
            }
            let Ok(fmt_name) = CString::new(format!("opacity_fmt{idx}")) else {
                bail!(graph, "CString::new failed for format name");
            };
            let mut fmt_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut fmt_ctx,
                fmt_filter,
                fmt_name.as_ptr(),
                c"yuva420p".as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create format filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, fmt_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →format layer={idx}"));
            }
            chain_end = fmt_ctx;

            // ── colorchannelmixer: scale alpha plane by opacity ────────────────
            let ccm_filter = ff_sys::avfilter_get_by_name(c"colorchannelmixer".as_ptr());
            if ccm_filter.is_null() {
                bail!(graph, "filter not found: colorchannelmixer");
            }
            let Ok(ccm_name) = CString::new(format!("ccm{idx}")) else {
                bail!(graph, "CString::new failed for colorchannelmixer name");
            };
            let Ok(ccm_args) = CString::new(format!("aa={opacity_initial:.6}")) else {
                bail!(graph, "CString::new failed for colorchannelmixer args");
            };
            let mut ccm_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut ccm_ctx,
                ccm_filter,
                ccm_name.as_ptr(),
                ccm_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create colorchannelmixer filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, ccm_ctx, 0);
            if ret < 0 {
                bail!(
                    graph,
                    format!("link failed: →colorchannelmixer layer={idx}")
                );
            }
            chain_end = ccm_ctx;

            // Register animation entry so tick() can update alpha per-frame.
            if let AnimatedValue::Track(ref track) = layer.opacity {
                animations.push(AnimationEntry {
                    node_name: format!("ccm{idx}"),
                    param: "aa",
                    track: track.clone(),
                    suffix: "",
                });
            }
        } else if opacity_initial < 1.0 {
            // Static opacity < 1.0: legacy path.
            let ccm_filter = ff_sys::avfilter_get_by_name(c"colorchannelmixer".as_ptr());
            if ccm_filter.is_null() {
                bail!(graph, "filter not found: colorchannelmixer");
            }
            let Ok(ccm_name) = CString::new(format!("ccm{idx}")) else {
                bail!(graph, "CString::new failed for colorchannelmixer name");
            };
            let Ok(ccm_args) = CString::new(format!("aa={opacity_initial}")) else {
                bail!(graph, "CString::new failed for colorchannelmixer args");
            };
            let mut ccm_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut ccm_ctx,
                ccm_filter,
                ccm_name.as_ptr(),
                ccm_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create colorchannelmixer filter layer={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, ccm_ctx, 0);
            if ret < 0 {
                bail!(
                    graph,
                    format!("link failed: →colorchannelmixer layer={idx}")
                );
            }
            chain_end = ccm_ctx;
        }

        // ── xfade (when this layer has an in_transition) ──────────────────────
        if let Some(ref t) = layer.in_transition {
            if !saved_chain.is_null() {
                // Wire: clip A (saved_chain) × clip B (chain_end) → xfade
                let xfade_filter = ff_sys::avfilter_get_by_name(c"xfade".as_ptr());
                if xfade_filter.is_null() {
                    bail!(graph, "filter not found: xfade");
                }
                let xfade_args_str = format!(
                    "transition={}:duration={}:offset={}",
                    t.kind.as_str(),
                    t.duration_secs,
                    t.offset_secs,
                );
                let Ok(xfade_args) = CString::new(xfade_args_str.as_str()) else {
                    bail!(graph, "CString::new failed for xfade args");
                };
                let Ok(xfade_name) = CString::new(format!("xfade{idx}")) else {
                    bail!(graph, "CString::new failed for xfade name");
                };
                let mut xfade_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
                let ret = ff_sys::avfilter_graph_create_filter(
                    &raw mut xfade_ctx,
                    xfade_filter,
                    xfade_name.as_ptr(),
                    xfade_args.as_ptr(),
                    std::ptr::null_mut(),
                    graph,
                );
                if ret < 0 {
                    bail!(
                        graph,
                        format!("failed to create xfade filter layer={idx} code={ret}")
                    );
                }
                // clip A → xfade input 0; clip B → xfade input 1
                let ret = ff_sys::avfilter_link(saved_chain, 0, xfade_ctx, 0);
                if ret < 0 {
                    bail!(graph, format!("link failed: A→xfade[0] layer={idx}"));
                }
                let ret = ff_sys::avfilter_link(chain_end, 0, xfade_ctx, 1);
                if ret < 0 {
                    bail!(graph, format!("link failed: B→xfade[1] layer={idx}"));
                }
                saved_chain = std::ptr::null_mut();
                chain_end = xfade_ctx;
                log::debug!(
                    "video composition xfade layer={idx} kind={} dur={} offset={}",
                    t.kind.as_str(),
                    t.duration_secs,
                    t.offset_secs,
                );
            } else {
                log::warn!(
                    "video composition layer={idx} has in_transition but no preceding \
                     layer on same z_order; hard cut applied"
                );
            }
        }

        // ── Skip overlay (save chain_end for upcoming xfade) or overlay ────────
        if skip_overlay[idx] {
            // The next layer will xfade from this one; defer the overlay.
            saved_chain = chain_end;
        } else {
            // ── overlay ───────────────────────────────────────────────────────────
            // Last layer uses eof_action=endall so the graph terminates when that
            // layer's source ends.  Intermediate layers use pass so the canvas
            // continues while other layers are still producing.
            //
            // When the overlay input has an alpha channel (animated opacity path),
            // `format=auto` tells FFmpeg to blend using that alpha.
            let eof_action = if is_last { "endall" } else { "pass" };
            let overlay_filter = ff_sys::avfilter_get_by_name(c"overlay".as_ptr());
            if overlay_filter.is_null() {
                bail!(graph, "filter not found: overlay");
            }
            let Ok(ov_name) = CString::new(format!("overlay{idx}")) else {
                bail!(graph, "CString::new failed for overlay name");
            };
            let lx = layer.x.value_at(Duration::ZERO).round() as i64;
            let ly = layer.y.value_at(Duration::ZERO).round() as i64;
            let needs_eval_frame = matches!(layer.x, AnimatedValue::Track(_))
                || matches!(layer.y, AnimatedValue::Track(_));
            let eval_suffix = if needs_eval_frame { ":eval=frame" } else { "" };
            let format_suffix = if is_animated_opacity {
                ":format=auto"
            } else {
                ""
            };
            let Ok(ov_args) = CString::new(format!(
                "{lx}:{ly}:eof_action={eof_action}{eval_suffix}{format_suffix}"
            )) else {
                bail!(graph, "CString::new failed for overlay args");
            };
            let mut overlay_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut overlay_ctx,
                overlay_filter,
                ov_name.as_ptr(),
                ov_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create overlay filter layer={idx} code={ret}")
                );
            }
            // Link: base canvas → overlay pad 0
            let ret = ff_sys::avfilter_link(prev_ctx, 0, overlay_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: base→overlay[0] layer={idx}"));
            }
            // Link: layer content → overlay pad 1
            let ret = ff_sys::avfilter_link(chain_end, 0, overlay_ctx, 1);
            if ret < 0 {
                bail!(graph, format!("link failed: layer→overlay[1] layer={idx}"));
            }

            // Register animation entries for animated x/y.
            if let AnimatedValue::Track(ref track) = layer.x {
                animations.push(AnimationEntry {
                    node_name: format!("overlay{idx}"),
                    param: "x",
                    track: track.clone(),
                    suffix: "",
                });
            }
            if let AnimatedValue::Track(ref track) = layer.y {
                animations.push(AnimationEntry {
                    node_name: format!("overlay{idx}"),
                    param: "y",
                    track: track.clone(),
                    suffix: "",
                });
            }

            prev_ctx = overlay_ctx;
        }
    }

    // ── format=yuv420p: constrain output pixel format before sink ────────────
    let vfmt_filter = ff_sys::avfilter_get_by_name(c"format".as_ptr());
    if vfmt_filter.is_null() {
        bail!(graph, "filter not found: format");
    }
    let mut vfmt_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut vfmt_ctx,
        vfmt_filter,
        c"vformat".as_ptr(),
        c"pix_fmts=yuv420p".as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create format filter code={ret}"));
    }
    let ret = ff_sys::avfilter_link(prev_ctx, 0, vfmt_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: last→format");
    }
    log::debug!("video composition format filter inserted output=yuv420p");

    // ── Video buffersink ──────────────────────────────────────────────────────
    let sink_filter = ff_sys::avfilter_get_by_name(c"buffersink".as_ptr());
    if sink_filter.is_null() {
        bail!(graph, "filter not found: buffersink");
    }
    let mut sink_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut sink_ctx,
        sink_filter,
        c"vsink".as_ptr(),
        std::ptr::null_mut(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create buffersink code={ret}"));
    }
    let ret = ff_sys::avfilter_link(vfmt_ctx, 0, sink_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: format→buffersink");
    }

    // ── Configure graph ───────────────────────────────────────────────────────
    let ret = ff_sys::avfilter_graph_config(graph, std::ptr::null_mut());
    if ret < 0 {
        log::warn!("video composition avfilter_graph_config failed code={ret}");
        bail!(graph, format!("avfilter_graph_config failed code={ret}"));
    }

    // SAFETY: ret >= 0 guarantees both pointers are non-null.
    let graph_nn = NonNull::new_unchecked(graph);
    let sink_nn = NonNull::new_unchecked(sink_ctx);
    let inner = FilterGraphInner::with_prebuilt_video_graph(graph_nn, sink_nn);
    log::info!(
        "video composition graph built layers={layer_count} canvas={canvas_width}x{canvas_height}"
    );
    if animations.is_empty() {
        Ok(FilterGraph::from_prebuilt(inner))
    } else {
        Ok(FilterGraph::from_prebuilt_animated(inner, animations))
    }
}

// ── Audio mix graph builder ───────────────────────────────────────────────────

pub(super) unsafe fn build_audio_mix(
    sample_rate: u32,
    channel_layout: ChannelLayout,
    tracks: &[AudioTrack],
) -> Result<FilterGraph, FilterError> {
    use std::ffi::CString;

    macro_rules! bail {
        ($graph:ident, $reason:expr) => {{
            let mut g = $graph;
            ff_sys::avfilter_graph_free(std::ptr::addr_of_mut!(g));
            return Err(FilterError::CompositionFailed {
                reason: format!("{}", $reason),
            });
        }};
    }

    let graph = ff_sys::avfilter_graph_alloc();
    if graph.is_null() {
        return Err(FilterError::CompositionFailed {
            reason: "avfilter_graph_alloc failed".to_string(),
        });
    }

    let track_count = tracks.len();
    let mut end_ctxs: Vec<*mut ff_sys::AVFilterContext> = Vec::with_capacity(track_count);
    let mut animations: Vec<AnimationEntry> = Vec::new();

    for (idx, track) in tracks.iter().enumerate() {
        let path = track
            .source
            .to_string_lossy()
            .replace('\\', "/")
            .replace(':', "\\:");

        // ── amovie= source ────────────────────────────────────────────────────
        let amovie_filter = ff_sys::avfilter_get_by_name(c"amovie".as_ptr());
        if amovie_filter.is_null() {
            bail!(graph, "filter not found: amovie");
        }
        let Ok(amovie_name) = CString::new(format!("amovie{idx}")) else {
            bail!(graph, "CString::new failed for amovie name");
        };
        let Ok(amovie_args) = CString::new(format!("filename={path}")) else {
            bail!(graph, "CString::new failed for amovie args");
        };
        let mut amovie_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
        let ret = ff_sys::avfilter_graph_create_filter(
            &raw mut amovie_ctx,
            amovie_filter,
            amovie_name.as_ptr(),
            amovie_args.as_ptr(),
            std::ptr::null_mut(),
            graph,
        );
        if ret < 0 {
            bail!(
                graph,
                format!("failed to create amovie filter track={idx} code={ret}")
            );
        }
        log::debug!("audio mix track={idx} amovie source path={path}");
        let mut chain_end = amovie_ctx;

        // ── Optional aresample (sample rate conversion) ───────────────────────
        if track.sample_rate != sample_rate {
            let src_rate = track.sample_rate;
            let aresample_filter = ff_sys::avfilter_get_by_name(c"aresample".as_ptr());
            if aresample_filter.is_null() {
                bail!(graph, "filter not found: aresample");
            }
            let Ok(ar_name) = CString::new(format!("aresample{idx}")) else {
                bail!(graph, "CString::new failed for aresample name");
            };
            let Ok(ar_args) = CString::new(format!("{sample_rate}")) else {
                bail!(graph, "CString::new failed for aresample args");
            };
            let mut ar_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut ar_ctx,
                aresample_filter,
                ar_name.as_ptr(),
                ar_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create aresample filter track={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, ar_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: amovie→aresample track={idx}"));
            }
            chain_end = ar_ctx;
            log::info!(
                "audio track resampled track={idx} source_rate={src_rate} target_rate={sample_rate}"
            );
        }

        // ── Optional aformat (channel layout conversion) ──────────────────────
        if track.channel_layout != channel_layout {
            let af_args_str = match channel_layout {
                ChannelLayout::Other(_) => format!("sample_rates={sample_rate}"),
                _ => format!("channel_layouts={}", channel_layout.name()),
            };
            let aformat_filter = ff_sys::avfilter_get_by_name(c"aformat".as_ptr());
            if aformat_filter.is_null() {
                bail!(graph, "filter not found: aformat");
            }
            let Ok(af_name) = CString::new(format!("aformat_layout{idx}")) else {
                bail!(graph, "CString::new failed for aformat layout name");
            };
            let Ok(af_args) = CString::new(af_args_str.as_str()) else {
                bail!(graph, "CString::new failed for aformat layout args");
            };
            let mut af_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut af_ctx,
                aformat_filter,
                af_name.as_ptr(),
                af_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create aformat layout filter track={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, af_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →aformat_layout track={idx}"));
            }
            chain_end = af_ctx;
            log::debug!(
                "audio track reformatted track={idx} layout={}",
                channel_layout.name()
            );
        }

        // ── Optional timeline offset ──────────────────────────────────────────
        // adelay inserts real silence samples, which is required for correct
        // multi-track mixing via amix (unlike asetpts, which only shifts PTS).
        if track.time_offset > Duration::ZERO {
            let delay_ms = track.time_offset.as_millis();
            let adelay_filter = ff_sys::avfilter_get_by_name(c"adelay".as_ptr());
            if adelay_filter.is_null() {
                bail!(graph, "filter not found: adelay");
            }
            let Ok(ad_name) = CString::new(format!("adelay{idx}")) else {
                bail!(graph, "CString::new failed for adelay name");
            };
            // all=1 applies the same delay to every channel
            let Ok(ad_args) = CString::new(format!("{delay_ms}:all=1")) else {
                bail!(graph, "CString::new failed for adelay args");
            };
            let mut ad_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut ad_ctx,
                adelay_filter,
                ad_name.as_ptr(),
                ad_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create adelay filter track={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, ad_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →adelay track={idx}"));
            }
            chain_end = ad_ctx;
            log::debug!("audio track delayed track={idx} delay_ms={delay_ms}");
        }

        // ── Volume (always inserted so the node can be targeted by send_command) ─
        {
            // Warn if animated pan was requested — not yet implemented.
            if matches!(track.pan, AnimatedValue::Track(_)) {
                log::warn!("animated pan not supported; using initial value track_index={idx}");
            }

            let vol_db = track.volume.value_at(Duration::ZERO);
            let node_name = format!("audio_{idx}_volume");
            let vol_filter = ff_sys::avfilter_get_by_name(c"volume".as_ptr());
            if vol_filter.is_null() {
                bail!(graph, "filter not found: volume");
            }
            let Ok(vol_name) = CString::new(node_name.as_str()) else {
                bail!(graph, "CString::new failed for volume name");
            };
            let Ok(vol_args) = CString::new(format!("{vol_db}dB")) else {
                bail!(graph, "CString::new failed for volume args");
            };
            let mut vol_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut vol_ctx,
                vol_filter,
                vol_name.as_ptr(),
                vol_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create volume filter track={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, vol_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →volume track={idx}"));
            }
            chain_end = vol_ctx;

            // Register animation entry if the volume is time-varying.
            // The `volume` filter option is a string expression: append "dB" so
            // that `apply_animations` sends e.g. "-60.000000dB" not "-60.000000".
            if let AnimatedValue::Track(ref vol_track) = track.volume {
                animations.push(AnimationEntry {
                    node_name: node_name.clone(),
                    param: "volume",
                    track: vol_track.clone(),
                    suffix: "dB",
                });
            }
        }

        // ── Per-track effects chain ───────────────────────────────────────────
        for (eff_idx, step) in track.effects.iter().enumerate() {
            let combined_idx = idx * 1000 + eff_idx;
            let result =
                crate::filter_inner::add_and_link_step(graph, chain_end, step, combined_idx, "eff");
            if let Ok(ctx) = result {
                chain_end = ctx;
            } else {
                bail!(
                    graph,
                    format!(
                        "failed to apply effect track={idx} effect={eff_idx} filter={}",
                        step.filter_name()
                    )
                );
            }
        }

        end_ctxs.push(chain_end);
    }

    // ── amix ──────────────────────────────────────────────────────────────────
    let amix_filter = ff_sys::avfilter_get_by_name(c"amix".as_ptr());
    if amix_filter.is_null() {
        bail!(graph, "filter not found: amix");
    }
    let Ok(amix_args) = CString::new(format!("inputs={track_count}:normalize=0")) else {
        bail!(graph, "CString::new failed for amix args");
    };
    let mut amix_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut amix_ctx,
        amix_filter,
        c"amix".as_ptr(),
        amix_args.as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create amix filter code={ret}"));
    }
    for (i, &end_ctx) in end_ctxs.iter().enumerate() {
        let ret = ff_sys::avfilter_link(end_ctx, 0, amix_ctx, i as u32);
        if ret < 0 {
            bail!(graph, format!("link failed: track{i}→amix[{i}]"));
        }
    }

    // ── aformat ───────────────────────────────────────────────────────────────
    let aformat_args_str = match channel_layout {
        ChannelLayout::Other(_) => format!("sample_rates={sample_rate}"),
        _ => format!(
            "sample_rates={sample_rate}:channel_layouts={}",
            channel_layout.name()
        ),
    };
    let aformat_filter = ff_sys::avfilter_get_by_name(c"aformat".as_ptr());
    if aformat_filter.is_null() {
        bail!(graph, "filter not found: aformat");
    }
    let Ok(aformat_args) = CString::new(aformat_args_str.as_str()) else {
        bail!(graph, "CString::new failed for aformat args");
    };
    let mut aformat_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut aformat_ctx,
        aformat_filter,
        c"aformat".as_ptr(),
        aformat_args.as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create aformat filter code={ret}"));
    }
    let ret = ff_sys::avfilter_link(amix_ctx, 0, aformat_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: amix→aformat");
    }

    // ── abuffersink ───────────────────────────────────────────────────────────
    let sink_filter = ff_sys::avfilter_get_by_name(c"abuffersink".as_ptr());
    if sink_filter.is_null() {
        bail!(graph, "filter not found: abuffersink");
    }
    let mut sink_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut sink_ctx,
        sink_filter,
        c"asink".as_ptr(),
        std::ptr::null_mut(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create abuffersink code={ret}"));
    }
    let ret = ff_sys::avfilter_link(aformat_ctx, 0, sink_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: aformat→abuffersink");
    }

    // ── Configure graph ───────────────────────────────────────────────────────
    let ret = ff_sys::avfilter_graph_config(graph, std::ptr::null_mut());
    if ret < 0 {
        log::warn!("audio mix avfilter_graph_config failed code={ret}");
        bail!(graph, format!("avfilter_graph_config failed code={ret}"));
    }

    // SAFETY: ret >= 0 guarantees both pointers are non-null.
    let graph_nn = NonNull::new_unchecked(graph);
    let sink_nn = NonNull::new_unchecked(sink_ctx);
    let inner = FilterGraphInner::with_prebuilt_audio_graph(graph_nn, sink_nn);
    log::info!("audio mix graph built tracks={track_count} sample_rate={sample_rate}");
    Ok(FilterGraph::from_prebuilt_animated(inner, animations))
}

// ── Video concat graph builder ────────────────────────────────────────────────

pub(super) unsafe fn build_video_concat(
    clips: &[PathBuf],
    output_width: Option<u32>,
    output_height: Option<u32>,
) -> Result<FilterGraph, FilterError> {
    use std::ffi::CString;

    macro_rules! bail {
        ($graph:ident, $reason:expr) => {{
            let mut g = $graph;
            ff_sys::avfilter_graph_free(std::ptr::addr_of_mut!(g));
            return Err(FilterError::CompositionFailed {
                reason: format!("{}", $reason),
            });
        }};
    }

    let graph = ff_sys::avfilter_graph_alloc();
    if graph.is_null() {
        return Err(FilterError::CompositionFailed {
            reason: "avfilter_graph_alloc failed".to_string(),
        });
    }

    let clip_count = clips.len();
    let mut end_ctxs: Vec<*mut ff_sys::AVFilterContext> = Vec::with_capacity(clip_count);

    for (idx, clip) in clips.iter().enumerate() {
        let path = clip
            .to_string_lossy()
            .replace('\\', "/")
            .replace(':', "\\:");

        // ── movie= source ─────────────────────────────────────────────────────
        let movie_filter = ff_sys::avfilter_get_by_name(c"movie".as_ptr());
        if movie_filter.is_null() {
            bail!(graph, "filter not found: movie");
        }
        let Ok(movie_name) = CString::new(format!("concat_movie{idx}")) else {
            bail!(graph, "CString::new failed for movie name");
        };
        let Ok(movie_args) = CString::new(format!("filename={path}")) else {
            bail!(graph, "CString::new failed for movie args");
        };
        let mut movie_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
        let ret = ff_sys::avfilter_graph_create_filter(
            &raw mut movie_ctx,
            movie_filter,
            movie_name.as_ptr(),
            movie_args.as_ptr(),
            std::ptr::null_mut(),
            graph,
        );
        if ret < 0 {
            bail!(
                graph,
                format!("failed to create movie filter clip={idx} code={ret}")
            );
        }
        log::debug!("video concat clip={idx} movie source path={path}");
        let mut chain_end = movie_ctx;

        // ── Optional scale ────────────────────────────────────────────────────
        if let (Some(w), Some(h)) = (output_width, output_height) {
            let scale_filter = ff_sys::avfilter_get_by_name(c"scale".as_ptr());
            if scale_filter.is_null() {
                bail!(graph, "filter not found: scale");
            }
            let Ok(sc_name) = CString::new(format!("concat_scale{idx}")) else {
                bail!(graph, "CString::new failed for scale name");
            };
            let Ok(sc_args) = CString::new(format!("{w}:{h}")) else {
                bail!(graph, "CString::new failed for scale args");
            };
            let mut sc_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut sc_ctx,
                scale_filter,
                sc_name.as_ptr(),
                sc_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create scale filter clip={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, sc_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: movie→scale clip={idx}"));
            }
            chain_end = sc_ctx;
        }

        end_ctxs.push(chain_end);
    }

    // ── concat (skipped for single clip) ─────────────────────────────────────
    let pre_sink_ctx = if clip_count == 1 {
        end_ctxs[0]
    } else {
        let concat_filter = ff_sys::avfilter_get_by_name(c"concat".as_ptr());
        if concat_filter.is_null() {
            bail!(graph, "filter not found: concat");
        }
        let Ok(concat_args) = CString::new(format!("n={clip_count}:v=1:a=0")) else {
            bail!(graph, "CString::new failed for concat args");
        };
        let mut concat_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
        let ret = ff_sys::avfilter_graph_create_filter(
            &raw mut concat_ctx,
            concat_filter,
            c"concat".as_ptr(),
            concat_args.as_ptr(),
            std::ptr::null_mut(),
            graph,
        );
        if ret < 0 {
            bail!(graph, format!("failed to create concat filter code={ret}"));
        }
        for (i, &end_ctx) in end_ctxs.iter().enumerate() {
            let ret = ff_sys::avfilter_link(end_ctx, 0, concat_ctx, i as u32);
            if ret < 0 {
                bail!(graph, format!("link failed: clip{i}→concat[{i}]"));
            }
        }
        concat_ctx
    };

    // ── format=yuv420p: constrain output pixel format before sink ────────────
    let vfmt_filter = ff_sys::avfilter_get_by_name(c"format".as_ptr());
    if vfmt_filter.is_null() {
        bail!(graph, "filter not found: format");
    }
    let mut vfmt_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut vfmt_ctx,
        vfmt_filter,
        c"vformat".as_ptr(),
        c"pix_fmts=yuv420p".as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create format filter code={ret}"));
    }
    let ret = ff_sys::avfilter_link(pre_sink_ctx, 0, vfmt_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: last→format");
    }
    log::debug!("video concat format filter inserted output=yuv420p");

    // ── buffersink ────────────────────────────────────────────────────────────
    let sink_filter = ff_sys::avfilter_get_by_name(c"buffersink".as_ptr());
    if sink_filter.is_null() {
        bail!(graph, "filter not found: buffersink");
    }
    let mut sink_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut sink_ctx,
        sink_filter,
        c"vsink".as_ptr(),
        std::ptr::null_mut(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create buffersink code={ret}"));
    }
    let ret = ff_sys::avfilter_link(vfmt_ctx, 0, sink_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: format→buffersink");
    }

    // ── Configure graph ───────────────────────────────────────────────────────
    let ret = ff_sys::avfilter_graph_config(graph, std::ptr::null_mut());
    if ret < 0 {
        log::warn!("video concat avfilter_graph_config failed code={ret}");
        bail!(graph, format!("avfilter_graph_config failed code={ret}"));
    }

    // SAFETY: ret >= 0 guarantees both pointers are non-null.
    let graph_nn = NonNull::new_unchecked(graph);
    let sink_nn = NonNull::new_unchecked(sink_ctx);
    let inner = FilterGraphInner::with_prebuilt_video_graph(graph_nn, sink_nn);
    log::info!("video concat graph built clips={clip_count}");
    Ok(FilterGraph::from_prebuilt(inner))
}

// ── Audio concat graph builder ────────────────────────────────────────────────

pub(super) unsafe fn build_audio_concat(
    clips: &[PathBuf],
    output_sample_rate: Option<u32>,
    output_channel_layout: Option<ChannelLayout>,
) -> Result<FilterGraph, FilterError> {
    use std::ffi::CString;

    macro_rules! bail {
        ($graph:ident, $reason:expr) => {{
            let mut g = $graph;
            ff_sys::avfilter_graph_free(std::ptr::addr_of_mut!(g));
            return Err(FilterError::CompositionFailed {
                reason: format!("{}", $reason),
            });
        }};
    }

    let graph = ff_sys::avfilter_graph_alloc();
    if graph.is_null() {
        return Err(FilterError::CompositionFailed {
            reason: "avfilter_graph_alloc failed".to_string(),
        });
    }

    let clip_count = clips.len();
    let mut end_ctxs: Vec<*mut ff_sys::AVFilterContext> = Vec::with_capacity(clip_count);

    for (idx, clip) in clips.iter().enumerate() {
        let path = clip
            .to_string_lossy()
            .replace('\\', "/")
            .replace(':', "\\:");

        // ── amovie= source ────────────────────────────────────────────────────
        let amovie_filter = ff_sys::avfilter_get_by_name(c"amovie".as_ptr());
        if amovie_filter.is_null() {
            bail!(graph, "filter not found: amovie");
        }
        let Ok(amovie_name) = CString::new(format!("aconcat_amovie{idx}")) else {
            bail!(graph, "CString::new failed for amovie name");
        };
        let Ok(amovie_args) = CString::new(format!("filename={path}")) else {
            bail!(graph, "CString::new failed for amovie args");
        };
        let mut amovie_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
        let ret = ff_sys::avfilter_graph_create_filter(
            &raw mut amovie_ctx,
            amovie_filter,
            amovie_name.as_ptr(),
            amovie_args.as_ptr(),
            std::ptr::null_mut(),
            graph,
        );
        if ret < 0 {
            bail!(
                graph,
                format!("failed to create amovie filter clip={idx} code={ret}")
            );
        }
        log::debug!("audio concat clip={idx} amovie source path={path}");
        let mut chain_end = amovie_ctx;

        // ── Optional aresample (sample rate conversion) ───────────────────────
        if let Some(sample_rate) = output_sample_rate {
            let aresample_filter = ff_sys::avfilter_get_by_name(c"aresample".as_ptr());
            if aresample_filter.is_null() {
                bail!(graph, "filter not found: aresample");
            }
            let Ok(ar_name) = CString::new(format!("aconcat_aresample{idx}")) else {
                bail!(graph, "CString::new failed for aresample name");
            };
            let Ok(ar_args) = CString::new(format!("{sample_rate}")) else {
                bail!(graph, "CString::new failed for aresample args");
            };
            let mut ar_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut ar_ctx,
                aresample_filter,
                ar_name.as_ptr(),
                ar_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create aresample filter clip={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, ar_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: amovie→aresample clip={idx}"));
            }
            chain_end = ar_ctx;
            log::debug!("audio concat clip={idx} aresample target_rate={sample_rate}");
        }

        // ── Optional aformat (channel layout conversion) ──────────────────────
        if let Some(layout) = output_channel_layout {
            let af_args_str = match layout {
                ChannelLayout::Other(_) => {
                    let sr = output_sample_rate.unwrap_or(48_000);
                    format!("sample_rates={sr}")
                }
                _ => format!("channel_layouts={}", layout.name()),
            };
            let aformat_filter = ff_sys::avfilter_get_by_name(c"aformat".as_ptr());
            if aformat_filter.is_null() {
                bail!(graph, "filter not found: aformat");
            }
            let Ok(af_name) = CString::new(format!("aconcat_aformat{idx}")) else {
                bail!(graph, "CString::new failed for aformat name");
            };
            let Ok(af_args) = CString::new(af_args_str.as_str()) else {
                bail!(graph, "CString::new failed for aformat args");
            };
            let mut af_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
            let ret = ff_sys::avfilter_graph_create_filter(
                &raw mut af_ctx,
                aformat_filter,
                af_name.as_ptr(),
                af_args.as_ptr(),
                std::ptr::null_mut(),
                graph,
            );
            if ret < 0 {
                bail!(
                    graph,
                    format!("failed to create aformat filter clip={idx} code={ret}")
                );
            }
            let ret = ff_sys::avfilter_link(chain_end, 0, af_ctx, 0);
            if ret < 0 {
                bail!(graph, format!("link failed: →aformat clip={idx}"));
            }
            chain_end = af_ctx;
            log::debug!("audio concat clip={idx} aformat layout={}", layout.name());
        }

        end_ctxs.push(chain_end);
    }

    // ── concat (skipped for single clip) ─────────────────────────────────────
    let pre_sink_ctx = if clip_count == 1 {
        end_ctxs[0]
    } else {
        let concat_filter = ff_sys::avfilter_get_by_name(c"concat".as_ptr());
        if concat_filter.is_null() {
            bail!(graph, "filter not found: concat");
        }
        let Ok(concat_args) = CString::new(format!("n={clip_count}:v=0:a=1")) else {
            bail!(graph, "CString::new failed for concat args");
        };
        let mut concat_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
        let ret = ff_sys::avfilter_graph_create_filter(
            &raw mut concat_ctx,
            concat_filter,
            c"aconcat".as_ptr(),
            concat_args.as_ptr(),
            std::ptr::null_mut(),
            graph,
        );
        if ret < 0 {
            bail!(graph, format!("failed to create concat filter code={ret}"));
        }
        for (i, &end_ctx) in end_ctxs.iter().enumerate() {
            let ret = ff_sys::avfilter_link(end_ctx, 0, concat_ctx, i as u32);
            if ret < 0 {
                bail!(graph, format!("link failed: clip{i}→aconcat[{i}]"));
            }
        }
        concat_ctx
    };

    // ── abuffersink ───────────────────────────────────────────────────────────
    let sink_filter = ff_sys::avfilter_get_by_name(c"abuffersink".as_ptr());
    if sink_filter.is_null() {
        bail!(graph, "filter not found: abuffersink");
    }
    let mut sink_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut sink_ctx,
        sink_filter,
        c"aconcat_asink".as_ptr(),
        std::ptr::null_mut(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create abuffersink code={ret}"));
    }
    let ret = ff_sys::avfilter_link(pre_sink_ctx, 0, sink_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: last→abuffersink");
    }

    // ── Configure graph ───────────────────────────────────────────────────────
    let ret = ff_sys::avfilter_graph_config(graph, std::ptr::null_mut());
    if ret < 0 {
        log::warn!("audio concat avfilter_graph_config failed code={ret}");
        bail!(graph, format!("avfilter_graph_config failed code={ret}"));
    }

    // SAFETY: ret >= 0 guarantees both pointers are non-null.
    let graph_nn = NonNull::new_unchecked(graph);
    let sink_nn = NonNull::new_unchecked(sink_ctx);
    let inner = FilterGraphInner::with_prebuilt_audio_graph(graph_nn, sink_nn);
    log::info!("audio concat graph built clips={clip_count}");
    Ok(FilterGraph::from_prebuilt(inner))
}

// ── Dissolve-join graph builder ────────────────────────────────────────────────

/// Probe a clip's duration in seconds using `avformat_open_input` +
/// `avformat_find_stream_info`.  Returns `CompositionFailed` if the file
/// cannot be opened or has an unknown duration.
pub(super) unsafe fn probe_clip_duration_sec(path: &PathBuf) -> Result<f64, FilterError> {
    let ctx = ff_sys::avformat::open_input(path.as_ref()).map_err(|code| {
        FilterError::CompositionFailed {
            reason: format!(
                "failed to probe clip duration code={code} path={}",
                path.display()
            ),
        }
    })?;

    if let Err(code) = ff_sys::avformat::find_stream_info(ctx) {
        let mut p = ctx;
        ff_sys::avformat::close_input(&raw mut p);
        return Err(FilterError::CompositionFailed {
            reason: format!("avformat_find_stream_info failed code={code}"),
        });
    }

    let duration_val = (*ctx).duration;
    let mut p = ctx;
    ff_sys::avformat::close_input(&raw mut p);

    if duration_val <= 0 {
        return Err(FilterError::CompositionFailed {
            reason: format!("clip has unknown duration path={}", path.display()),
        });
    }
    // AV_TIME_BASE = 1_000_000 microseconds per second.
    Ok(duration_val as f64 / 1_000_000.0)
}

pub(super) unsafe fn build_dissolve_join(
    clip_a: &PathBuf,
    clip_b: &PathBuf,
    dissolve_sec: f64,
) -> Result<FilterGraph, FilterError> {
    use std::ffi::CString;

    macro_rules! bail {
        ($graph:ident, $reason:expr) => {{
            let mut g = $graph;
            ff_sys::avfilter_graph_free(std::ptr::addr_of_mut!(g));
            return Err(FilterError::CompositionFailed {
                reason: format!("{}", $reason),
            });
        }};
    }

    // ── Dissolve-duration == 0: use concat (plain concatenation) ──────────────
    if dissolve_sec == 0.0 {
        return build_video_concat(&[clip_a.clone(), clip_b.clone()], None, None);
    }

    // ── Probe clip durations ──────────────────────────────────────────────────
    let clip_a_dur = probe_clip_duration_sec(clip_a)?;
    let clip_b_dur = probe_clip_duration_sec(clip_b)?;

    if dissolve_sec > clip_a_dur {
        return Err(FilterError::CompositionFailed {
            reason: format!(
                "dissolve_duration ({dissolve_sec:.3}s) exceeds clip_a duration ({clip_a_dur:.3}s)"
            ),
        });
    }
    if dissolve_sec > clip_b_dur {
        return Err(FilterError::CompositionFailed {
            reason: format!(
                "dissolve_duration ({dissolve_sec:.3}s) exceeds clip_b duration ({clip_b_dur:.3}s)"
            ),
        });
    }

    // xfade offset = when the crossfade starts (measured from the beginning of
    // the first stream).
    let xfade_offset = clip_a_dur - dissolve_sec;

    // ── Allocate graph ────────────────────────────────────────────────────────
    let graph = ff_sys::avfilter_graph_alloc();
    if graph.is_null() {
        return Err(FilterError::CompositionFailed {
            reason: "avfilter_graph_alloc failed".to_string(),
        });
    }

    // ── movie[a] source ───────────────────────────────────────────────────────
    let movie_filter = ff_sys::avfilter_get_by_name(c"movie".as_ptr());
    if movie_filter.is_null() {
        bail!(graph, "filter not found: movie");
    }
    let path_a = clip_a.to_string_lossy();
    let Ok(movie_a_name) = CString::new("jd_movie_a") else {
        bail!(graph, "CString::new failed for movie_a name");
    };
    let Ok(movie_a_args) = CString::new(format!("filename={path_a}")) else {
        bail!(graph, "CString::new failed for movie_a args");
    };
    let mut movie_a_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut movie_a_ctx,
        movie_filter,
        movie_a_name.as_ptr(),
        movie_a_args.as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create movie_a filter code={ret}"));
    }
    log::debug!("dissolve join clip_a movie source path={path_a}");

    // ── movie[b] source ───────────────────────────────────────────────────────
    let path_b = clip_b.to_string_lossy();
    let Ok(movie_b_name) = CString::new("jd_movie_b") else {
        bail!(graph, "CString::new failed for movie_b name");
    };
    let Ok(movie_b_args) = CString::new(format!("filename={path_b}")) else {
        bail!(graph, "CString::new failed for movie_b args");
    };
    let mut movie_b_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut movie_b_ctx,
        movie_filter,
        movie_b_name.as_ptr(),
        movie_b_args.as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create movie_b filter code={ret}"));
    }
    log::debug!("dissolve join clip_b movie source path={path_b}");

    // ── xfade ─────────────────────────────────────────────────────────────────
    let xfade_filter = ff_sys::avfilter_get_by_name(c"xfade".as_ptr());
    if xfade_filter.is_null() {
        bail!(graph, "filter not found: xfade");
    }
    let xfade_args_str =
        format!("transition=dissolve:duration={dissolve_sec}:offset={xfade_offset}");
    let Ok(xfade_args) = CString::new(xfade_args_str.as_str()) else {
        bail!(graph, "CString::new failed for xfade args");
    };
    let mut xfade_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut xfade_ctx,
        xfade_filter,
        c"jd_xfade".as_ptr(),
        xfade_args.as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(
            graph,
            format!("failed to create xfade filter args={xfade_args_str} code={ret}")
        );
    }
    log::debug!("dissolve join xfade args={xfade_args_str}");

    // movie_a → xfade[0]
    let ret = ff_sys::avfilter_link(movie_a_ctx, 0, xfade_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: movie_a→xfade[0]");
    }
    // movie_b → xfade[1]
    let ret = ff_sys::avfilter_link(movie_b_ctx, 0, xfade_ctx, 1);
    if ret < 0 {
        bail!(graph, "link failed: movie_b→xfade[1]");
    }

    // ── format=yuv420p: constrain output pixel format before sink ────────────
    let vfmt_filter = ff_sys::avfilter_get_by_name(c"format".as_ptr());
    if vfmt_filter.is_null() {
        bail!(graph, "filter not found: format");
    }
    let mut vfmt_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut vfmt_ctx,
        vfmt_filter,
        c"jd_vformat".as_ptr(),
        c"pix_fmts=yuv420p".as_ptr(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create format filter code={ret}"));
    }
    let ret = ff_sys::avfilter_link(xfade_ctx, 0, vfmt_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: xfade→format");
    }
    log::debug!("dissolve join format filter inserted output=yuv420p");

    // ── buffersink ────────────────────────────────────────────────────────────
    let sink_filter = ff_sys::avfilter_get_by_name(c"buffersink".as_ptr());
    if sink_filter.is_null() {
        bail!(graph, "filter not found: buffersink");
    }
    let mut sink_ctx: *mut ff_sys::AVFilterContext = std::ptr::null_mut();
    let ret = ff_sys::avfilter_graph_create_filter(
        &raw mut sink_ctx,
        sink_filter,
        c"jd_vsink".as_ptr(),
        std::ptr::null_mut(),
        std::ptr::null_mut(),
        graph,
    );
    if ret < 0 {
        bail!(graph, format!("failed to create buffersink code={ret}"));
    }
    let ret = ff_sys::avfilter_link(vfmt_ctx, 0, sink_ctx, 0);
    if ret < 0 {
        bail!(graph, "link failed: format→buffersink");
    }

    // ── Configure graph ───────────────────────────────────────────────────────
    let ret = ff_sys::avfilter_graph_config(graph, std::ptr::null_mut());
    if ret < 0 {
        log::warn!("dissolve join avfilter_graph_config failed code={ret}");
        bail!(graph, format!("avfilter_graph_config failed code={ret}"));
    }

    // SAFETY: ret >= 0 guarantees both pointers are non-null.
    let graph_nn = NonNull::new_unchecked(graph);
    let sink_nn = NonNull::new_unchecked(sink_ctx);
    let inner = FilterGraphInner::with_prebuilt_video_graph(graph_nn, sink_nn);
    log::info!("dissolve join graph built dissolve_sec={dissolve_sec} xfade_offset={xfade_offset}");
    Ok(FilterGraph::from_prebuilt(inner))
}
