use crate::color::{
    normalize_linear_f32, identity_ccm, mat_mul_vec3, mat_mul_3x3, camera_to_xyz_matrix, interpolate_matrix,
    BilinearDemosaic, ColorSpace, TransferFunction, build_bradford_matrix, D65_XYZ,
    forward_to_camera_xyz, detect_camera_to_xyz,
};
use crate::decoder::Decoder;
use crate::encoder::VideoEncoder;
use crate::export::{Av1Profile, CodecFamily, DnxhrProfile, H264Profile, HevcProfile, ProResProfile, RateControl, Vp9Profile};
use crate::file::McrawFileInfo;
use crate::gpu;
use crate::stats::{PhaseGuard, PipelineStats};
use anyhow::{anyhow, Result};
use crossbeam_channel::bounded;
use rayon::prelude::*;
use std::fs;
use std::io::{BufWriter, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;

const PIPELINE_DEPTH: usize = 3;
struct FrameSlot { bayer: Vec<u16>, frame_bytes: Vec<u8>, as_shot_neutral: [f32; 3] }

pub fn build_ffmpeg_codec_args(family: CodecFamily, hevc_encoder: &str, h264_encoder: &str, av1_encoder: &str, prores_encoder: &str, prores: ProResProfile, dnxhr: DnxhrProfile, hevc: HevcProfile, h264: H264Profile, av1: Av1Profile, vp9: Vp9Profile, rate_control: &RateControl, is_wide_gamut: bool) -> (String, String, Vec<String>) {
    family.to_ffmpeg_args(hevc_encoder, h264_encoder, av1_encoder, prores_encoder, prores, dnxhr, hevc, h264, av1, vp9, rate_control, is_wide_gamut)
}

/// Map our `ColorSpace` / `TransferFunction` to **valid** FFmpeg VUI codes.
///
/// FFmpeg only accepts a fixed enum of ITU-R / SMPTE color tags. Camera-vendor
/// gamuts (S-Gamut3, ARRI WG, V-Gamut, etc.) have no standard code, so we
/// signal `bt2020` (the closest superset wide-gamut tag) for primaries and
/// `bt2020nc` for the matrix coefficients. Log curves with no standard TRC
/// code are signalled as `unknown` — downstream tools should rely on the
/// filename / sidecar to identify the actual curve.
///
/// Returning an empty vec is also valid (FFmpeg will simply omit VUI tags),
/// but we always emit something so the bitstream is self-describing.
pub fn get_ffmpeg_vui_tags(color_space: &ColorSpace, transfer: &TransferFunction) -> Vec<&'static str> {
    let (primaries, matrix) = match color_space {
        ColorSpace::Rec709 | ColorSpace::Srgb => ("bt709", "bt709"),
        ColorSpace::Rec2020 => ("bt2020", "bt2020nc"),
        ColorSpace::DciP3 => ("smpte431", "bt2020nc"),
        ColorSpace::DisplayP3 => ("smpte432", "bt2020nc"),
        // Camera-vendor wide gamuts: no standard FFmpeg tag exists.
        // Signal the closest superset (bt2020) so the bitstream is at least
        // syntactically valid and decoders treat it as wide-gamut content.
        ColorSpace::FGamut
        | ColorSpace::FGamutC
        | ColorSpace::SGamut3
        | ColorSpace::SGamut3Cine
        | ColorSpace::ARRIWideGamut3
        | ColorSpace::ARRIWideGamut4
        | ColorSpace::CanonCinemaGamut
        | ColorSpace::PanasonicVGamut
        | ColorSpace::DaVinciWideGamut
        | ColorSpace::ACESAP1 => ("bt2020", "bt2020nc"),
    };
    let trc = match transfer {
        TransferFunction::Rec709 => "bt709",
        // Display gamma 2.4 has no dedicated FFmpeg code; bt709 is the
        // standard display-referred tag and is the safest choice.
        TransferFunction::Gamma24 => "bt709",
        TransferFunction::HLG => "arib-std-b67",
        TransferFunction::PQ => "smpte2084",
        TransferFunction::Linear => "linear",
        // Camera log curves (S-Log3, V-Log, ARRI LogC3, C-Log3, F-Log2,
        // Apple Log, ACEScct, DaVinci Intermediate) have no standard
        // FFmpeg/ITU TRC code. `unknown` tells decoders not to attempt
        // any inverse-OETF — the metadata / filename identifies the curve.
        _ => "unknown",
    };
    vec!["-color_primaries", primaries, "-color_trc", trc, "-colorspace", matrix]
}

pub fn run_naked(info: &McrawFileInfo, output_path: &str) -> Result<()> {
    tracing::info!("run_naked: input={} output={}", info.path, output_path);
    let _stats = Arc::new(PipelineStats::new());
    let decoder = Decoder::new(&info.path)?; let timestamps = decoder.timestamps()?;
    if timestamps.is_empty() { return Err(anyhow!("No frames found in file")); }
    let mut out_file = fs::File::create(output_path)?;
    for ts in &timestamps { let (bayer, _meta) = decoder.load_frame(*ts)?; for &v in &bayer { out_file.write_all(&v.to_le_bytes())?; } }
    Ok(())
}

pub fn run(info: &McrawFileInfo, output_path: &str) -> Result<()> {
    let never_cancel = Arc::new(AtomicBool::new(false));
    let stats = Arc::new(PipelineStats::new());
    run_export(info.clone(), output_path.to_string(), Arc::new(|_| {}), never_cancel, stats, ColorSpace::Rec709, TransferFunction::Rec709, CodecFamily::ProRes, ProResProfile::HQ, DnxhrProfile::HQX, HevcProfile::Main10_420, H264Profile::Main8bit, Av1Profile::Profile0_420_10bit, Vp9Profile::Profile2_420_10bit, "libx265".to_string(), "libx264".to_string(), "libaom-av1".to_string(), "prores_ks".to_string(), RateControl::Lossless)
}

#[allow(clippy::too_many_arguments)]
pub fn run_export(info: McrawFileInfo, output_path: String, on_progress: Arc<dyn Fn(f64) + Send + Sync>, cancelled: Arc<AtomicBool>, stats: Arc<PipelineStats>, export_cs: ColorSpace, export_tf: TransferFunction, codec_family: CodecFamily, prores_profile: ProResProfile, dnxhr_profile: DnxhrProfile, hevc_profile: HevcProfile, h264_profile: H264Profile, av1_profile: Av1Profile, vp9_profile: Vp9Profile, hevc_encoder: String, h264_encoder: String, av1_encoder: String, prores_encoder: String, rate_control: RateControl) -> Result<()> {
    tracing::info!("run_export: input={} output={} codec={} cs={} tf={}", info.path, output_path, codec_family.name(), export_cs.name(), export_tf.name());
    let setup_start = Instant::now();
    let decoder = Decoder::new(&info.path)?; let timestamps = decoder.timestamps()?;
    if timestamps.is_empty() { return Err(anyhow!("No frames found in file")); }
    let stride_width = info.width as u32; let offset_x = info.active_offset_x as u32; let offset_y = info.active_offset_y as u32;
    let active_width = if info.active_width > 0 { info.active_width as u32 } else { stride_width };
    let active_height = if info.active_height > 0 { info.active_height as u32 } else { info.height as u32 };
    if active_width == 0 || active_height == 0 { return Err(anyhow!("Invalid active dimensions")); }
    let fps = if info.fps > 0.0 { info.fps } else { 25.0 };
    
    let cm1_f32: [f32; 9] = info.camera_metadata.color_matrix.map(|cm| { let mut ccm = [0.0f32; 9]; for (i, v) in cm.iter().enumerate() { ccm[i] = *v as f32; } ccm }).unwrap_or_else(identity_ccm);
    let cm2_f32: Option<[f32; 9]> = info.camera_metadata.color_matrix2.map(|cm| { let mut ccm = [0.0f32; 9]; for (i, v) in cm.iter().enumerate() { ccm[i] = *v as f32; } ccm });
    let fm1_f32: Option<[f32; 9]> = info.camera_metadata.forward_matrix1.map(|fm| { let mut ccm = [0.0f32; 9]; for (i, v) in fm.iter().enumerate() { ccm[i] = *v as f32; } ccm });
    let fm2_f32: Option<[f32; 9]> = info.camera_metadata.forward_matrix2.map(|fm| { let mut ccm = [0.0f32; 9]; for (i, v) in fm.iter().enumerate() { ccm[i] = *v as f32; } ccm });
    let cal1_f32: Option<[f32; 9]> = info.camera_metadata.calibration_matrix1.map(|cm| { let mut ccm = [0.0f32; 9]; for (i, v) in cm.iter().enumerate() { ccm[i] = *v as f32; } ccm });
    let cal2_f32: Option<[f32; 9]> = info.camera_metadata.calibration_matrix2.map(|cm| { let mut ccm = [0.0f32; 9]; for (i, v) in cm.iter().enumerate() { ccm[i] = *v as f32; } ccm });
    
    let cm_has_values = cm1_f32.iter().any(|&v| v.abs() > 0.01 && v.abs() < 10.0);
    let mut matrix_path = "";
    // The matrix that takes white-balanced camera RGB into XYZ under the
    // matrix's reference illuminant. Two DNG conventions:
    //   * ColorMatrix1: Camera-WB -> XYZ under illuminant1 (often D65 or
    //     Standard A, sometimes custom). Row sums equal that illuminant.
    //     Needs a Bradford CAT to D65 before applying xyz_to_Rec.709.
    //   * ForwardMatrix1: Camera-WB -> XYZ under D50 (assumes camera
    //     WB is applied via AsShotNeutral). Row sums equal D50
    //     [0.96422, 1.0, 0.82521]. Needs only a fixed D50->D65 CAT for
    //     a Rec.709 (D65) output.
    //
    // The DNG spec says to prefer ForwardMatrix1 over ColorMatrix1 when
    // both are present because fm1 already incorporates scene WB and
    // D50 adaptation. We therefore select fm1 if present, falling back
    // to cm1 (with a per-frame Bradford CAT computed from the actual
    // scene white in XYZ) only if no fm1 is available.
    let cam_to_xyz: [f32; 9] = if let (Some(ref fm1), Some(ref fm2)) = (fm1_f32, fm2_f32) {
        matrix_path = "ForwardMatrix1+2 → D50 (preferred)";
        let fm_avg = interpolate_matrix(fm1, fm2, 0.5);
        // fm1 is already Camera-WB -> D50; no orientation test needed.
        // Verify it's the right orientation: row sums should match D50.
        let rs = [fm_avg[0] + fm_avg[1] + fm_avg[2],
                  fm_avg[3] + fm_avg[4] + fm_avg[5],
                  fm_avg[6] + fm_avg[7] + fm_avg[8]];
        let d50 = crate::color::D50_XYZ;
        let d = (rs[0]-d50[0]).powi(2) + (rs[1]-d50[1]).powi(2) + (rs[2]-d50[2]).powi(2);
        if d < 0.05 {
            fm_avg
        } else {
            matrix_path = "ForwardMatrix1+2 (orientation-adjusted)";
            detect_camera_to_xyz(&fm_avg)
        }
    } else if let Some(ref fm1) = fm1_f32 {
        matrix_path = "ForwardMatrix1 → D50 (preferred)";
        let rs = [fm1[0] + fm1[1] + fm1[2],
                  fm1[3] + fm1[4] + fm1[5],
                  fm1[6] + fm1[7] + fm1[8]];
        let d50 = crate::color::D50_XYZ;
        let d = (rs[0]-d50[0]).powi(2) + (rs[1]-d50[1]).powi(2) + (rs[2]-d50[2]).powi(2);
        if d < 0.05 {
            *fm1
        } else {
            matrix_path = "ForwardMatrix1 (orientation-adjusted)";
            detect_camera_to_xyz(fm1)
        }
    } else if cm_has_values {
        matrix_path = "ColorMatrix1 → XYZ (no fm1 fallback)";
        let cal = cal1_f32.or(cal2_f32);
        match cm2_f32 {
            Some(ref cm2) => {
                let cm_avg = interpolate_matrix(&cm1_f32, cm2, 0.5);
                camera_to_xyz_matrix(&cm_avg, cal.as_ref())
            }
            None => camera_to_xyz_matrix(&cm1_f32, cal.as_ref()),
        }
    } else {
        matrix_path = "IDENTITY";
        identity_ccm()
    };
    tracing::info!("matrix path: {} | cam_to_xyz diag=[{:.3},{:.3},{:.3}]", matrix_path, cam_to_xyz[0], cam_to_xyz[4], cam_to_xyz[8]);
    // Suppress unused-import warning when nothing forwards to a transform that
    // we might still want to log. Kept for parity with detector heuristics.
    let _ = detect_camera_to_xyz(&cam_to_xyz);

    let xyz_to_output = export_cs.get_xyz_to_rgb_matrix();
    // The cam_to_xyz matrix maps Camera-WB -> XYZ under some reference
    // illuminant. For the GPU fused matrix we pre-bake a constant Bradford
    // CAT from that illuminant to D65 (so the output is D65-based, e.g.
    // Rec.709). The per-frame CPU path additionally re-applies the CAT
    // using the actual scene white in XYZ for the case where the
    // illuminant is unknown (cm1 path) — see `fused` construction below.
    let rs = [cam_to_xyz[0] + cam_to_xyz[1] + cam_to_xyz[2],
              cam_to_xyz[3] + cam_to_xyz[4] + cam_to_xyz[5],
              cam_to_xyz[6] + cam_to_xyz[7] + cam_to_xyz[8]];
    let cam_illuminant_xyz = if matrix_path.starts_with("ForwardMatrix") {
        // fm1 maps to D50; row sums ARE D50 (within 0.05).
        crate::color::D50_XYZ
    } else {
        // cm1 (or identity) — use the row sums as the implied illuminant.
        // If row sums look degenerate (e.g. all near zero or far from any
        // known illuminant), fall back to D50.
        let l = rs[0].max(rs[1]).max(rs[2]);
        if l < 0.1 || l > 5.0 { crate::color::D50_XYZ } else { rs }
    };
    let bradford_static = build_bradford_matrix(&cam_illuminant_xyz, &D65_XYZ);
    let cam_to_xyz_d65 = mat_mul_3x3(&bradford_static, &cam_to_xyz);
    let fused = mat_mul_3x3(&xyz_to_output, &cam_to_xyz_d65);
    tracing::info!("fused matrix cs={}: [{:.6},{:.6},{:.6} | {:.6},{:.6},{:.6} | {:.6},{:.6},{:.6}]",
        export_cs.name(),
        fused[0], fused[1], fused[2],
        fused[3], fused[4], fused[5],
        fused[6], fused[7], fused[8],
    );
    let pattern = info.bayer_pattern; let black_level = info.black_level; let white_level = info.white_level; let total_frames = timestamps.len();
    let is_wide_gamut = export_cs != ColorSpace::Rec709 && export_cs != ColorSpace::Srgb;
    
    let (codec_name, pix_fmt, mut extra_args) = build_ffmpeg_codec_args(codec_family, &hevc_encoder, &h264_encoder, &av1_encoder, &prores_encoder, prores_profile, dnxhr_profile, hevc_profile, h264_profile, av1_profile, vp9_profile, &rate_control, is_wide_gamut);
    let vui_tags = get_ffmpeg_vui_tags(&export_cs, &export_tf); extra_args.extend(vui_tags.into_iter().map(String::from));
    
    let audio_temp_path = if info.has_audio && info.audio_sample_rate > 0 && info.audio_channels > 0 {
        let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos();
        let temp_path = std::env::temp_dir().join(format!("mcraw_audio_{}.raw", ts));
        match std::fs::File::create(&temp_path) {
            Ok(file) => { let mut writer = BufWriter::new(file); match decoder.write_audio_to(&mut writer) { Ok(()) => { let _ = writer.flush(); Some(temp_path) } Err(e) => { let _ = std::fs::remove_file(&temp_path); None } } }
            Err(_) => None
        }
    } else { None };
    
    let mut encoder = VideoEncoder::new(&output_path, active_width, active_height, fps, &codec_name, &pix_fmt, &extra_args, audio_temp_path.as_deref(), info.audio_sample_rate, info.audio_channels)?;
    let stride_pixels = stride_width as usize * info.height as usize; let pixel_count = (active_width * active_height) as usize; let bytes_per_frame = pixel_count * 6;
    let (free_tx, free_rx) = bounded::<FrameSlot>(PIPELINE_DEPTH); let (loaded_tx, loaded_rx) = bounded::<FrameSlot>(PIPELINE_DEPTH); let (processed_tx, processed_rx) = bounded::<FrameSlot>(PIPELINE_DEPTH);
    for _ in 0..PIPELINE_DEPTH { free_tx.send(FrameSlot { bayer: vec![0u16; stride_pixels], frame_bytes: vec![0u8; bytes_per_frame], as_shot_neutral: [0.0; 3] })?; }
    let free_tx_writer = free_tx.clone();
    
    let filters = pattern.to_dcraw_filters(); let mut rcd_pipeline: Option<gpu::RcdPipeline> = None;
    match pollster::block_on(gpu::GpuContext::new()) {
        Ok(ctx) => { let ctx = std::sync::Arc::new(ctx); match gpu::RcdPipeline::new(ctx, active_width, active_height) { Ok(pipeline) => { rcd_pipeline = Some(pipeline); } Err(_) => {} } }
        Err(_) => {}
    }
    stats.setup.record(setup_start.elapsed());
    
    let loader_handle = std::thread::Builder::new().name("loader".into()).spawn({
        let cancelled = cancelled.clone();
        let stats = Arc::clone(&stats);
        move || -> Result<()> {
            for (i, ts) in timestamps.iter().enumerate() {
                if cancelled.load(Ordering::Relaxed) { break; }
                let mut slot = free_rx.recv().map_err(|_| anyhow!("Loader: free pool closed"))?;
                let as_shot_neutral = {
                    let _g = PhaseGuard::new(&stats.decode);
                    decoder.load_frame_into(*ts, &mut slot.bayer)?
                };
                slot.as_shot_neutral = as_shot_neutral;
                // B4: hint the OS to prefetch the next frame's range.
                if let Some(next_ts) = timestamps.get(i + 1) {
                    decoder.prefetch(*next_ts);
                }
                loaded_tx.send(slot).map_err(|_| anyhow!("Loader: processor channel closed"))?;
            }
            drop(loaded_tx); Ok(())
        }
    })?;
    
    // AgX pipeline is intentionally disabled. It will be reintroduced
    // as a separate feature; for now the render path is scene-referred
    // raw → WB → highlight-recon → CCM → OETF.

    let processor_handle = std::thread::Builder::new().name("processor".into()).spawn({
        let cancelled = cancelled.clone();
        let stats = Arc::clone(&stats);
        move || -> Result<()> {
            let mut rgb = vec![0.0f32; pixel_count * 3]; let demosaic = BilinearDemosaic::new(pattern);
            for mut slot in loaded_rx {
                if cancelled.load(Ordering::Relaxed) { break; }
                stats.frames_total.fetch_add(1, Ordering::Relaxed);
                slot.frame_bytes.fill(0); let as_shot = slot.as_shot_neutral;

                // Per-frame fused matrix. Three cases:
                //
                //   1. cam_to_xyz = fm1: fm1 already maps Camera-WB -> D50
                //      XYZ. The only CAT needed is a fixed D50 -> D65 (which
                //      is baked into the static `fused` above). Re-using
                //      the static `fused` here is the correct and only
                //      consistent choice; the per-frame as_shot does NOT
                //      re-enter the matrix.
                //
                //   2. cam_to_xyz = cm1: cm1 maps Camera-WB -> XYZ under
                //      some other illuminant (e.g. D65, A, custom). The
                //      per-frame as_shot gives the actual scene white in
                //      XYZ (under the matrix's reference illuminant) and
                //      the Bradford CAT is computed from that.
                //
                //   3. cam_to_xyz = identity: no characterization, just
                //      use the static fused (which is xyz_to_Rec709
                //      applied to identity — produces non-neutral gray
                //      for D65 scenes; this is a known limitation when
                //      no matrix is provided).
                let fused = if matrix_path.starts_with("ForwardMatrix") {
                    // Use the precomputed static fused (D50 -> D65 -> Rec.709
                    // CAT already baked in).
                    fused
                } else if matrix_path.starts_with("ColorMatrix") {
                    // Per-frame CAT from cm1-as-shot-white to D65.
                    let neutral_under_d65 =
                        (as_shot[0] - 1.0).abs() < 1e-3 &&
                        (as_shot[1] - 1.0).abs() < 1e-3 &&
                        (as_shot[2] - 1.0).abs() < 1e-3;
                    if neutral_under_d65 {
                        fused
                    } else {
                        let scene_white_xyz = if as_shot[0] > 1e-6 && as_shot[1] > 1e-6 && as_shot[2] > 1e-6 {
                            let mut v = mat_mul_vec3(&cam_to_xyz, &as_shot);
                            v[0] = v[0].clamp(0.3, 3.0);
                            v[1] = v[1].clamp(0.3, 3.0);
                            v[2] = v[2].clamp(0.3, 3.0);
                            v
                        } else {
                            D65_XYZ
                        };
                        let bradford_adapt = build_bradford_matrix(&scene_white_xyz, &D65_XYZ);
                        let cam_to_xyz_d65 = mat_mul_3x3(&bradford_adapt, &cam_to_xyz);
                        mat_mul_3x3(&xyz_to_output, &cam_to_xyz_d65)
                    }
                } else {
                    fused
                };

                let gpu_ok = if let Some(ref mut pipeline) = rcd_pipeline {
                    let gpu_result = {
                        let _g = PhaseGuard::new(&stats.gpu);
                        pipeline.process(&slot.bayer, filters, black_level as f32, white_level as f32, stride_width, offset_x, offset_y, &fused, &slot.as_shot_neutral, &export_tf)
                    };
                    match gpu_result {
                        Ok(rgb48le) => {
                            stats.gpu_frames.fetch_add(1, Ordering::Relaxed);
                            slot.frame_bytes.copy_from_slice(&rgb48le);
                            true
                        } Err(_) => false
                    }
                } else { false };

                if !gpu_ok {
                    {
                        let _g = PhaseGuard::new(&stats.demosaic);
                        demosaic.process_par_into(&slot.bayer, stride_width, offset_x, offset_y, active_width, active_height, &pattern, &mut rgb)?;
                    }
                    {
                        let _g = PhaseGuard::new(&stats.normalize);
                        normalize_linear_f32(&mut rgb, black_level as f32, white_level as f32);
                    }

                    let raw_r_gain = if as_shot[0] > 1e-6 && as_shot[1] > 1e-6 { as_shot[1] / as_shot[0] } else { 1.0 };
                    let raw_b_gain = if as_shot[2] > 1e-6 && as_shot[1] > 1e-6 { as_shot[1] / as_shot[2] } else { 1.0 };
                    tracing::info!("as_shot=[{:.4},{:.4},{:.4}] raw_wb_r={:.4} raw_wb_b={:.4}", as_shot[0], as_shot[1], as_shot[2], raw_r_gain, raw_b_gain);
                    let r_gain = raw_r_gain.clamp(0.1, 10.0);
                    let b_gain = raw_b_gain.clamp(0.1, 10.0);
                    if (r_gain - raw_r_gain).abs() > 1e-3 || (b_gain - raw_b_gain).abs() > 1e-3 {
                        tracing::warn!(
                            "CPU WB gains clamped: as_shot={:?} raw=[{:.3},{:.3}] clamped=[{:.3},{:.3}]",
                            as_shot, raw_r_gain, raw_b_gain, r_gain, b_gain
                        );
                    }
                    {
                        let _g = PhaseGuard::new(&stats.wb_hl_ccm);
                        let is_display_referred = matches!(export_tf,
                            TransferFunction::Rec709
                            | TransferFunction::Gamma24
                            | TransferFunction::Linear
                        );
                        rgb.par_chunks_exact_mut(3).for_each(|chunk| {
                            // 1. Apply WB
                            let r = chunk[0] * r_gain;
                            let g = chunk[1];
                            let b = chunk[2] * b_gain;

                            if is_display_referred {
                                // 2. Highlight reconstruction — desaturate toward
                                //    neutral when the pixel is blown. Prevents
                                //    magenta-shifted highlights when a single
                                //    channel clips (common with speculars).
                                let max_val = r.max(g).max(b);
                                let neutral = max_val.min(1.0);
                                let t = if max_val > 0.95_f32 { ((max_val - 0.95) / 0.05).min(1.0) } else { 0.0 };
                                let (r, g, b) = if t > 0.0 {
                                    (r + (neutral - r) * t, g + (neutral - g) * t, b + (neutral - b) * t)
                                } else {
                                    (r, g, b)
                                };

                                // 3. Apply CCM
                                let out = mat_mul_vec3(&fused, &[r, g, b]);
                                chunk[0] = out[0].max(0.0); chunk[1] = out[1].max(0.0); chunk[2] = out[2].max(0.0);
                            } else {
                                // 2. Log curve path: skip highlight reconstruction.
                                //    Log OETFs encode 4-100× of dynamic range and
                                //    naturally handle values above 1.0. No
                                //    reconstruction needed — clamping would
                                //    destroy highlight detail.
                                // 3. Apply CCM
                                let out = mat_mul_vec3(&fused, &[r, g, b]);
                                let mut r_ccm = out[0].max(0.0);
                                let mut g_ccm = out[1].max(0.0);
                                let mut b_ccm = out[2].max(0.0);

                                // Gamut soft-clip: desaturate extreme out-of-gamut
                                // values (>1.0) toward luminance. Prevents "wild
                                // highlight peaks" from wide-gamut matrices
                                // (DWG, CanonCG, SG3C) while preserving in-gamut
                                // colorimetry perfectly.
                                let max_val = r_ccm.max(g_ccm.max(b_ccm));
                                if max_val > 1.0 {
                                    let lum = 0.2126_f32 * r_ccm + 0.7152_f32 * g_ccm + 0.0722_f32 * b_ccm;
                                    let lum = lum.min(max_val).max(0.0);
                                    let t = ((max_val - 1.0) * 1.0).min(1.0);
                                    r_ccm += (lum - r_ccm) * t;
                                    g_ccm += (lum - g_ccm) * t;
                                    b_ccm += (lum - b_ccm) * t;
                                }

                                chunk[0] = r_ccm;
                                chunk[1] = g_ccm;
                                chunk[2] = b_ccm;
                            }
                        });
                    }
                    {
                        let _g = PhaseGuard::new(&stats.oetf);
                        export_tf.process(&mut rgb);
                    }

                    {
                        let _g = PhaseGuard::new(&stats.pack);
                        slot.frame_bytes.par_chunks_exact_mut(6).enumerate().for_each(|(pi, out)| {
                            let base = pi * 3;
                            let ru = (rgb[base].clamp(0.0, 1.0) * 65535.0) as u16; let gu = (rgb[base + 1].clamp(0.0, 1.0) * 65535.0) as u16; let bu = (rgb[base + 2].clamp(0.0, 1.0) * 65535.0) as u16;
                            out[0] = ru as u8; out[1] = (ru >> 8) as u8; out[2] = gu as u8; out[3] = (gu >> 8) as u8; out[4] = bu as u8; out[5] = (bu >> 8) as u8;
                        });
                    }
                }
                processed_tx.send(slot).map_err(|_| anyhow!("Processor: writer channel closed"))?;
            }
            drop(processed_tx); Ok(())
        }
    })?;
    
    let writer_cancelled = cancelled.clone(); let ffmpeg_pid = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); ffmpeg_pid.store(encoder.pid(), Ordering::Relaxed);
    let monitor_cancelled = cancelled.clone(); let monitor_pid = ffmpeg_pid.clone();
    let _monitor_handle = std::thread::Builder::new().name("cancel_monitor".into()).spawn(move || {
        while !monitor_cancelled.load(Ordering::Relaxed) { std::thread::sleep(std::time::Duration::from_millis(50)); }
        let pid = monitor_pid.load(Ordering::Relaxed);
        if pid > 0 { #[cfg(target_os = "windows")] { let _ = std::process::Command::new("taskkill").args(["/F", "/PID", &pid.to_string()]).output(); } #[cfg(not(target_os = "windows"))] { let _ = std::process::Command::new("kill").args(["-TERM", &pid.to_string()]).output(); } }
    }).ok();
    
    let writer_handle = std::thread::Builder::new().name("writer".into()).spawn({
        let stats = Arc::clone(&stats);
        move || -> Result<()> {
            let mut frames_written: usize = 0;
            for slot in processed_rx {
                if writer_cancelled.load(Ordering::Relaxed) { let _ = free_tx_writer.send(slot); return Ok(()); }
                // C5: explicit start/end so we can tag the per-frame ring
                // with the frame_id. The `encode_push` PhaseTimer is still
                // updated (via record_encode_push_frame) for the histogram.
                let encode_start = std::time::Instant::now();
                encoder.push_frame(&slot.frame_bytes)?;
                stats.record_encode_push_frame(frames_written as u32, encode_start.elapsed());
                frames_written += 1; on_progress(frames_written as f64 / total_frames as f64 * 100.0); let _ = free_tx_writer.send(slot);
            }
            if writer_cancelled.load(Ordering::Relaxed) { return Ok(()); }
            {
                let _g = PhaseGuard::new(&stats.finalize);
                encoder.finish()?;
            }
            Ok(())
        }
    })?;
    
    let mut export_error: Option<anyhow::Error> = None;
    match loader_handle.join() { Ok(Ok(())) => {} Ok(Err(e)) => { cancelled.store(true, Ordering::Relaxed); export_error = Some(e); } Err(_) => { cancelled.store(true, Ordering::Relaxed); export_error = Some(anyhow!("Loader panicked")); } }
    match processor_handle.join() { Ok(Ok(())) => {} Ok(Err(e)) => { cancelled.store(true, Ordering::Relaxed); export_error.get_or_insert(e); } Err(_) => { cancelled.store(true, Ordering::Relaxed); export_error.get_or_insert(anyhow!("Processor panicked")); } }
    match writer_handle.join() { Ok(Ok(())) => {} Ok(Err(e)) => { export_error.get_or_insert(e); } Err(_) => { export_error.get_or_insert(anyhow!("Writer panicked")); } }
    if let Some(ref audio_path) = audio_temp_path { let _ = std::fs::remove_file(audio_path); }
    match export_error { Some(_) if cancelled.load(Ordering::Relaxed) => Err(anyhow!("Export cancelled")), Some(e) => Err(e), None => Ok(()) }
}