// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
// SPDX-License-Identifier: Apache-2.0

#![cfg(target_os = "linux")]

use crate::{CPUProcessor, Crop, Error, Flip, ImageProcessorTrait, Result, Rotation};
use edgefirst_tensor::{DType, PixelFormat, Tensor, TensorDyn, TensorMapTrait, TensorTrait};
use four_char_code::FourCharCode;
use g2d_sys::{G2DFormat, G2DPhysical, G2DSurface, G2D};
use std::{os::fd::AsRawFd, time::Instant};

/// Convert a PixelFormat to the G2D-compatible FourCharCode.
fn pixelfmt_to_fourcc(fmt: PixelFormat) -> FourCharCode {
    use four_char_code::four_char_code;
    match fmt {
        PixelFormat::Rgb => four_char_code!("RGB "),
        PixelFormat::Rgba => four_char_code!("RGBA"),
        PixelFormat::Bgra => four_char_code!("BGRA"),
        PixelFormat::Grey => four_char_code!("Y800"),
        PixelFormat::Yuyv => four_char_code!("YUYV"),
        PixelFormat::Vyuy => four_char_code!("VYUY"),
        PixelFormat::Nv12 => four_char_code!("NV12"),
        PixelFormat::Nv16 => four_char_code!("NV16"),
        // Planar formats have no standard FourCC; use RGBA as fallback
        _ => four_char_code!("RGBA"),
    }
}

/// G2DConverter implements the ImageProcessor trait using the NXP G2D
/// library for hardware-accelerated image processing on i.MX platforms.
#[derive(Debug)]
pub struct G2DProcessor {
    g2d: G2D,
}

unsafe impl Send for G2DProcessor {}
unsafe impl Sync for G2DProcessor {}

impl G2DProcessor {
    /// Creates a new G2DConverter instance.
    pub fn new() -> Result<Self> {
        let mut g2d = G2D::new("libg2d.so.2")?;
        g2d.set_bt709_colorspace()?;

        log::debug!("G2DConverter created with version {:?}", g2d.version());
        Ok(Self { g2d })
    }

    /// Returns the G2D library version as defined by _G2D_VERSION in the shared
    /// library.
    pub fn version(&self) -> g2d_sys::Version {
        self.g2d.version()
    }

    fn convert_impl(
        &mut self,
        src_dyn: &TensorDyn,
        dst_dyn: &mut TensorDyn,
        rotation: Rotation,
        flip: Flip,
        crop: Crop,
    ) -> Result<()> {
        let _span = tracing::trace_span!(
            "g2d_convert",
            src_fmt = ?src_dyn.format(),
            dst_fmt = ?dst_dyn.format(),
        )
        .entered();
        if log::log_enabled!(log::Level::Trace) {
            log::trace!(
                "G2D convert: {:?}({:?}/{:?}) → {:?}({:?}/{:?})",
                src_dyn.format(),
                src_dyn.dtype(),
                src_dyn.memory(),
                dst_dyn.format(),
                dst_dyn.dtype(),
                dst_dyn.memory(),
            );
        }

        if src_dyn.dtype() != DType::U8 {
            return Err(Error::NotSupported(
                "G2D only supports u8 source tensors".to_string(),
            ));
        }
        let is_int8_dst = dst_dyn.dtype() == DType::I8;
        if dst_dyn.dtype() != DType::U8 && !is_int8_dst {
            return Err(Error::NotSupported(
                "G2D only supports u8 or i8 destination tensors".to_string(),
            ));
        }

        let src_fmt = src_dyn.format().ok_or(Error::NotAnImage)?;
        let dst_fmt = dst_dyn.format().ok_or(Error::NotAnImage)?;

        // Validate supported format pairs
        use PixelFormat::*;
        match (src_fmt, dst_fmt) {
            (Rgba, Rgba) => {}
            (Rgba, Yuyv) => {}
            (Rgba, Rgb) => {}
            (Yuyv, Rgba) => {}
            (Yuyv, Yuyv) => {}
            (Yuyv, Rgb) => {}
            // VYUY: i.MX8MP G2D hardware rejects VYUY blits (only YUYV/UYVY
            // among packed YUV 4:2:2). ImageProcessor falls through to CPU.
            (Nv12, Rgba) => {}
            (Nv12, Yuyv) => {}
            (Nv12, Rgb) => {}
            (Rgba, Bgra) => {}
            (Yuyv, Bgra) => {}
            (Nv12, Bgra) => {}
            (Bgra, Bgra) => {}
            (s, d) => {
                return Err(Error::NotSupported(format!(
                    "G2D does not support {} to {} conversion",
                    s, d
                )));
            }
        }

        crop.check_crop_dyn(src_dyn, dst_dyn)?;

        let src = src_dyn.as_u8().unwrap();
        // For i8 destinations, reinterpret as u8 for G2D (same byte layout).
        // The XOR 0x80 post-pass is applied after the blit completes.
        let dst = if is_int8_dst {
            // SAFETY: Tensor<i8> and Tensor<u8> have identical memory layout.
            // The T parameter only affects PhantomData<T> (zero-sized) in
            // TensorStorage variants and the typed view from map(). The chroma
            // field (Option<Box<Tensor<T>>>) is also layout-identical. This
            // reinterpreted reference is used only for shape/fd access and the
            // G2D blit (which operates on raw DMA bytes). It does not outlive
            // dst_dyn and is never stored.
            let i8_tensor = dst_dyn.as_i8_mut().unwrap();
            unsafe { &mut *(i8_tensor as *mut Tensor<i8> as *mut Tensor<u8>) }
        } else {
            dst_dyn.as_u8_mut().unwrap()
        };

        let mut src_surface = tensor_to_g2d_surface(src)?;
        let mut dst_surface = tensor_to_g2d_surface(dst)?;

        src_surface.rot = match flip {
            Flip::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
            Flip::Vertical => g2d_sys::g2d_rotation_G2D_FLIP_V,
            Flip::Horizontal => g2d_sys::g2d_rotation_G2D_FLIP_H,
        };

        dst_surface.rot = match rotation {
            Rotation::None => g2d_sys::g2d_rotation_G2D_ROTATION_0,
            Rotation::Clockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_90,
            Rotation::Rotate180 => g2d_sys::g2d_rotation_G2D_ROTATION_180,
            Rotation::CounterClockwise90 => g2d_sys::g2d_rotation_G2D_ROTATION_270,
        };

        if let Some(crop_rect) = crop.src_rect {
            src_surface.left = crop_rect.left as i32;
            src_surface.top = crop_rect.top as i32;
            src_surface.right = (crop_rect.left + crop_rect.width) as i32;
            src_surface.bottom = (crop_rect.top + crop_rect.height) as i32;
        }

        let dst_w = dst.width().unwrap();
        let dst_h = dst.height().unwrap();

        // Clear the destination with the letterbox color before blitting the
        // image into the sub-region.
        //
        // g2d_clear does not support 3-byte-per-pixel formats (RGB888, BGR888).
        // For those formats, fall back to CPU fill after the blit.
        let needs_clear = crop.dst_color.is_some()
            && crop.dst_rect.is_some_and(|dst_rect| {
                dst_rect.left != 0
                    || dst_rect.top != 0
                    || dst_rect.width != dst_w
                    || dst_rect.height != dst_h
            });

        if needs_clear && dst_fmt != Rgb {
            if let Some(dst_color) = crop.dst_color {
                let start = Instant::now();
                self.g2d.clear(&mut dst_surface, dst_color)?;
                log::trace!("g2d clear takes {:?}", start.elapsed());
            }
        }

        if let Some(crop_rect) = crop.dst_rect {
            // stride is in pixels; multiply by bytes-per-pixel (== channels()
            // for u8 data) to get the byte offset.  All G2D destination
            // formats are packed, so channels() == bpp always holds here.
            dst_surface.planes[0] += ((crop_rect.top * dst_surface.stride as usize
                + crop_rect.left)
                * dst_fmt.channels()) as u64;

            dst_surface.right = crop_rect.width as i32;
            dst_surface.bottom = crop_rect.height as i32;
            dst_surface.width = crop_rect.width as i32;
            dst_surface.height = crop_rect.height as i32;
        }

        log::trace!("G2D blit: {src_fmt}→{dst_fmt} int8={is_int8_dst}");
        self.g2d.blit(&src_surface, &dst_surface)?;
        self.g2d.finish()?;
        log::trace!("G2D blit complete");

        // CPU fallback for RGB888 (unsupported by g2d_clear)
        if needs_clear && dst_fmt == Rgb {
            if let (Some(dst_color), Some(dst_rect)) = (crop.dst_color, crop.dst_rect) {
                let start = Instant::now();
                CPUProcessor::fill_image_outside_crop_u8(dst, dst_color, dst_rect)?;
                log::trace!("cpu fill takes {:?}", start.elapsed());
            }
        }

        // Apply XOR 0x80 for int8 output (u8→i8 bias conversion).
        // map() issues DMA_BUF_IOCTL_SYNC(START) on the dst fd; for self-allocated
        // CMA buffers this performs cache invalidation via the DrmAttachment.
        // For foreign fds (e.g. the Neutron NPU DMA-BUF imported via from_fd()),
        // the DrmAttachment is None and the sync ioctl is handled by the NPU driver.
        // The map drop issues DMA_BUF_IOCTL_SYNC(END) so the NPU DMA engine sees
        // the CPU-written XOR'd data on the next Invoke().
        if is_int8_dst {
            let start = Instant::now();
            let mut map = dst.map()?;
            crate::cpu::apply_int8_xor_bias(map.as_mut_slice(), dst_fmt);
            log::trace!("g2d int8 XOR 0x80 post-pass takes {:?}", start.elapsed());
        }

        Ok(())
    }
}

impl ImageProcessorTrait for G2DProcessor {
    fn convert(
        &mut self,
        src: &TensorDyn,
        dst: &mut TensorDyn,
        rotation: Rotation,
        flip: Flip,
        crop: Crop,
    ) -> Result<()> {
        self.convert_impl(src, dst, rotation, flip, crop)
    }

    fn draw_decoded_masks(
        &mut self,
        dst: &mut TensorDyn,
        detect: &[crate::DetectBox],
        segmentation: &[crate::Segmentation],
        overlay: crate::MaskOverlay<'_>,
    ) -> Result<()> {
        // G2D can produce the *frame* (background or cleared canvas) via
        // hardware primitives — but has no rasterizer for boxes / masks.
        // If detections are present, defer to another backend.
        if !detect.is_empty() || !segmentation.is_empty() {
            return Err(Error::NotImplemented(
                "G2D does not support drawing detection or segmentation overlays".to_string(),
            ));
        }
        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
    }

    fn draw_proto_masks(
        &mut self,
        dst: &mut TensorDyn,
        detect: &[crate::DetectBox],
        _proto_data: &crate::ProtoData,
        overlay: crate::MaskOverlay<'_>,
    ) -> Result<()> {
        // Same logic as draw_decoded_masks: G2D handles empty-detection
        // frames (clear or blit background) via the 2D hardware.
        if !detect.is_empty() {
            return Err(Error::NotImplemented(
                "G2D does not support drawing detection or segmentation overlays".to_string(),
            ));
        }
        draw_empty_frame_g2d(&mut self.g2d, dst, overlay.background)
    }

    fn set_class_colors(&mut self, _: &[[u8; 4]]) -> Result<()> {
        Err(Error::NotImplemented(
            "G2D does not support setting colors for rendering detection or segmentation overlays"
                .to_string(),
        ))
    }
}

/// Produce the zero-detection frame on `dst` using G2D hardware ops.
///
/// `background == None` → `g2d_clear(dst, 0x00000000)` — actively fills the
/// destination with transparent black using the 2D engine (no CPU touch).
///
/// `background == Some(bg)` → `g2d_blit(bg → dst)` — hardware copy of bg
/// into dst. This is the "draw the cleared overlay onto the background"
/// path; G2D has no pure copy primitive, so we use the blit engine with a
/// 1:1 same-format surface pair, which is the hardware equivalent.
///
/// Both paths `finish()` before returning so dst is coherent for any
/// downstream reader.
fn draw_empty_frame_g2d(
    g2d: &mut G2D,
    dst_dyn: &mut TensorDyn,
    background: Option<&TensorDyn>,
) -> Result<()> {
    if dst_dyn.dtype() != DType::U8 {
        return Err(Error::NotSupported(
            "G2D only supports u8 destination tensors".to_string(),
        ));
    }
    let dst = dst_dyn.as_u8_mut().ok_or(Error::NotAnImage)?;

    // DMA-only: G2D operates on physical addresses. A Mem-backed dst means
    // we're on the wrong backend — surface a dispatch error so the caller
    // can fall back.
    if dst.as_dma().is_none() {
        return Err(Error::NotImplemented(
            "g2d only supports Dma memory".to_string(),
        ));
    }

    let mut dst_surface = tensor_to_g2d_surface(dst)?;

    match background {
        None => {
            // Case 1 — no background, no detections: hardware clear to
            // transparent black. Every byte of dst is written by the 2D
            // engine; no reliance on prior state.
            let start = Instant::now();
            g2d.clear(&mut dst_surface, [0, 0, 0, 0])?;
            g2d.finish()?;
            log::trace!("g2d clear (empty frame) takes {:?}", start.elapsed());
        }
        Some(bg_dyn) => {
            // Case 2 — background, no detections: hardware blit bg → dst.
            // Validate shape/format equivalence; if the caller handed us
            // mismatched surfaces we return a structured error rather than
            // silently producing wrong output.
            if bg_dyn.shape() != dst.shape() {
                return Err(Error::InvalidShape(
                    "background shape does not match dst".into(),
                ));
            }
            if bg_dyn.format() != dst.format() {
                return Err(Error::InvalidShape(
                    "background pixel format does not match dst".into(),
                ));
            }
            if bg_dyn.dtype() != DType::U8 {
                return Err(Error::NotSupported(
                    "G2D only supports u8 background tensors".to_string(),
                ));
            }
            let bg = bg_dyn.as_u8().ok_or(Error::NotAnImage)?;
            if bg.as_dma().is_none() {
                return Err(Error::NotImplemented(
                    "g2d background must be Dma-backed".to_string(),
                ));
            }
            let src_surface = tensor_to_g2d_surface(bg)?;
            let start = Instant::now();
            g2d.blit(&src_surface, &dst_surface)?;
            g2d.finish()?;
            log::trace!("g2d blit (bg→dst) takes {:?}", start.elapsed());
        }
    }
    Ok(())
}

/// Build a `G2DSurface` from a `Tensor<u8>` that carries pixel-format metadata.
///
/// The tensor must be backed by DMA memory and must have a pixel format set.
fn tensor_to_g2d_surface(img: &Tensor<u8>) -> Result<G2DSurface> {
    let fmt = img.format().ok_or(Error::NotAnImage)?;
    let dma = img
        .as_dma()
        .ok_or_else(|| Error::NotImplemented("g2d only supports Dma memory".to_string()))?;
    let phys: G2DPhysical = dma.fd.as_raw_fd().try_into()?;

    // NV12 is a two-plane format: Y plane followed by interleaved UV plane.
    // planes[0] = Y plane start, planes[1] = UV plane start (Y size = width * height)
    //
    // plane_offset is the byte offset within the DMA-BUF where pixel data
    // starts.  G2D works with raw physical addresses so we must add the
    // offset ourselves — the hardware has no concept of a per-plane offset.
    let base_addr = phys.address();
    let luma_offset = img.plane_offset().unwrap_or(0) as u64;
    let planes = if fmt == PixelFormat::Nv12 {
        if img.is_multiplane() {
            // Multiplane: UV in separate DMA-BUF, get its physical address
            let chroma = img.chroma().unwrap();
            let chroma_dma = chroma.as_dma().ok_or_else(|| {
                Error::NotImplemented("g2d multiplane chroma must be DMA-backed".to_string())
            })?;
            let uv_phys: G2DPhysical = chroma_dma.fd.as_raw_fd().try_into()?;
            let chroma_offset = img.chroma().and_then(|c| c.plane_offset()).unwrap_or(0) as u64;
            [
                base_addr + luma_offset,
                uv_phys.address() + chroma_offset,
                0,
            ]
        } else {
            let w = img.width().unwrap();
            let h = img.height().unwrap();
            let stride = img.effective_row_stride().unwrap_or(w);
            let uv_offset = (luma_offset as usize + stride * h) as u64;
            [base_addr + luma_offset, base_addr + uv_offset, 0]
        }
    } else {
        [base_addr + luma_offset, 0, 0]
    };

    let w = img.width().unwrap();
    let h = img.height().unwrap();
    let fourcc = pixelfmt_to_fourcc(fmt);

    // G2D stride is in pixels.  effective_row_stride() returns bytes, so
    // divide by the bytes-per-pixel (channels for u8 data) to convert.
    let stride_pixels = match img.effective_row_stride() {
        Some(s) => {
            let channels = fmt.channels();
            if s % channels != 0 {
                return Err(Error::NotImplemented(
                    "g2d requires row stride to be a multiple of bytes-per-pixel".to_string(),
                ));
            }
            s / channels
        }
        None => w,
    };

    Ok(G2DSurface {
        planes,
        format: G2DFormat::try_from(fourcc)?.format(),
        left: 0,
        top: 0,
        right: w as i32,
        bottom: h as i32,
        stride: stride_pixels as i32,
        width: w as i32,
        height: h as i32,
        blendfunc: 0,
        clrcolor: 0,
        rot: 0,
        global_alpha: 0,
    })
}

#[cfg(feature = "g2d_test_formats")]
#[cfg(test)]
mod g2d_tests {
    use super::*;
    use crate::{CPUProcessor, Flip, G2DProcessor, ImageProcessorTrait, Rect, Rotation};
    use edgefirst_tensor::{
        is_dma_available, DType, PixelFormat, TensorDyn, TensorMapTrait, TensorMemory, TensorTrait,
    };
    use image::buffer::ConvertBuffer;

    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_formats_no_resize() {
        for i in [
            PixelFormat::Rgba,
            PixelFormat::Yuyv,
            PixelFormat::Rgb,
            PixelFormat::Grey,
            PixelFormat::Nv12,
        ] {
            for o in [
                PixelFormat::Rgba,
                PixelFormat::Yuyv,
                PixelFormat::Rgb,
                PixelFormat::Grey,
            ] {
                let res = test_g2d_format_no_resize_(i, o);
                if let Err(e) = res {
                    println!("{i} to {o} failed: {e:?}");
                } else {
                    println!("{i} to {o} success");
                }
            }
        }
    }

    fn test_g2d_format_no_resize_(
        g2d_in_fmt: PixelFormat,
        g2d_out_fmt: PixelFormat,
    ) -> Result<(), crate::Error> {
        let dst_width = 1280;
        let dst_height = 720;
        let file = include_bytes!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../testdata/zidane.jpg"
        ))
        .to_vec();
        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;

        // Create DMA buffer for G2D input
        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;

        let mut cpu_converter = CPUProcessor::new();

        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
        if g2d_in_fmt == PixelFormat::Nv12 {
            let nv12_bytes = include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/zidane.nv12"
            ));
            src2.as_u8()
                .unwrap()
                .map()?
                .as_mut_slice()
                .copy_from_slice(nv12_bytes);
        } else {
            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
        }

        let mut g2d_dst = TensorDyn::image(
            dst_width,
            dst_height,
            g2d_out_fmt,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let mut g2d_converter = G2DProcessor::new()?;
        let src2_dyn = src2;
        let mut g2d_dst_dyn = g2d_dst;
        g2d_converter.convert(
            &src2_dyn,
            &mut g2d_dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        g2d_dst = {
            let mut __t = g2d_dst_dyn.into_u8().unwrap();
            __t.set_format(g2d_out_fmt)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        let mut cpu_dst =
            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
        cpu_converter.convert(
            &g2d_dst,
            &mut cpu_dst,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;

        compare_images(
            &src,
            &cpu_dst,
            0.98,
            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}"),
        )
    }

    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_formats_with_resize() {
        for i in [
            PixelFormat::Rgba,
            PixelFormat::Yuyv,
            PixelFormat::Rgb,
            PixelFormat::Grey,
            PixelFormat::Nv12,
        ] {
            for o in [
                PixelFormat::Rgba,
                PixelFormat::Yuyv,
                PixelFormat::Rgb,
                PixelFormat::Grey,
            ] {
                let res = test_g2d_format_with_resize_(i, o);
                if let Err(e) = res {
                    println!("{i} to {o} failed: {e:?}");
                } else {
                    println!("{i} to {o} success");
                }
            }
        }
    }

    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_formats_with_resize_dst_crop() {
        for i in [
            PixelFormat::Rgba,
            PixelFormat::Yuyv,
            PixelFormat::Rgb,
            PixelFormat::Grey,
            PixelFormat::Nv12,
        ] {
            for o in [
                PixelFormat::Rgba,
                PixelFormat::Yuyv,
                PixelFormat::Rgb,
                PixelFormat::Grey,
            ] {
                let res = test_g2d_format_with_resize_dst_crop(i, o);
                if let Err(e) = res {
                    println!("{i} to {o} failed: {e:?}");
                } else {
                    println!("{i} to {o} success");
                }
            }
        }
    }

    fn test_g2d_format_with_resize_(
        g2d_in_fmt: PixelFormat,
        g2d_out_fmt: PixelFormat,
    ) -> Result<(), crate::Error> {
        let dst_width = 600;
        let dst_height = 400;
        let file = include_bytes!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../testdata/zidane.jpg"
        ))
        .to_vec();
        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;

        let mut cpu_converter = CPUProcessor::new();

        let mut reference = TensorDyn::image(
            dst_width,
            dst_height,
            PixelFormat::Rgb,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        cpu_converter.convert(
            &src,
            &mut reference,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;

        // Create DMA buffer for G2D input
        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;

        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
        if g2d_in_fmt == PixelFormat::Nv12 {
            let nv12_bytes = include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/zidane.nv12"
            ));
            src2.as_u8()
                .unwrap()
                .map()?
                .as_mut_slice()
                .copy_from_slice(nv12_bytes);
        } else {
            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
        }

        let mut g2d_dst = TensorDyn::image(
            dst_width,
            dst_height,
            g2d_out_fmt,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let mut g2d_converter = G2DProcessor::new()?;
        let src2_dyn = src2;
        let mut g2d_dst_dyn = g2d_dst;
        g2d_converter.convert(
            &src2_dyn,
            &mut g2d_dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        g2d_dst = {
            let mut __t = g2d_dst_dyn.into_u8().unwrap();
            __t.set_format(g2d_out_fmt)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        let mut cpu_dst =
            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
        cpu_converter.convert(
            &g2d_dst,
            &mut cpu_dst,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;

        compare_images(
            &reference,
            &cpu_dst,
            0.98,
            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized"),
        )
    }

    fn test_g2d_format_with_resize_dst_crop(
        g2d_in_fmt: PixelFormat,
        g2d_out_fmt: PixelFormat,
    ) -> Result<(), crate::Error> {
        let dst_width = 600;
        let dst_height = 400;
        let crop = Crop {
            src_rect: None,
            dst_rect: Some(Rect {
                top: 100,
                left: 100,
                height: 100,
                width: 200,
            }),
            dst_color: None,
        };
        let file = include_bytes!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../testdata/zidane.jpg"
        ))
        .to_vec();
        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;

        let mut cpu_converter = CPUProcessor::new();

        let mut reference = TensorDyn::image(
            dst_width,
            dst_height,
            PixelFormat::Rgb,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        reference
            .as_u8()
            .unwrap()
            .map()
            .unwrap()
            .as_mut_slice()
            .fill(128);
        cpu_converter.convert(&src, &mut reference, Rotation::None, Flip::None, crop)?;

        // Create DMA buffer for G2D input
        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;

        // For PixelFormat::Nv12 input, load from file since CPU doesn't support PixelFormat::Rgb→PixelFormat::Nv12
        if g2d_in_fmt == PixelFormat::Nv12 {
            let nv12_bytes = include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/zidane.nv12"
            ));
            src2.as_u8()
                .unwrap()
                .map()?
                .as_mut_slice()
                .copy_from_slice(nv12_bytes);
        } else {
            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
        }

        let mut g2d_dst = TensorDyn::image(
            dst_width,
            dst_height,
            g2d_out_fmt,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        g2d_dst
            .as_u8()
            .unwrap()
            .map()
            .unwrap()
            .as_mut_slice()
            .fill(128);
        let mut g2d_converter = G2DProcessor::new()?;
        let src2_dyn = src2;
        let mut g2d_dst_dyn = g2d_dst;
        g2d_converter.convert(
            &src2_dyn,
            &mut g2d_dst_dyn,
            Rotation::None,
            Flip::None,
            crop,
        )?;
        g2d_dst = {
            let mut __t = g2d_dst_dyn.into_u8().unwrap();
            __t.set_format(g2d_out_fmt)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        let mut cpu_dst =
            TensorDyn::image(dst_width, dst_height, PixelFormat::Rgb, DType::U8, None)?;
        cpu_converter.convert(
            &g2d_dst,
            &mut cpu_dst,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;

        compare_images(
            &reference,
            &cpu_dst,
            0.98,
            &format!("{g2d_in_fmt}_to_{g2d_out_fmt}_resized_dst_crop"),
        )
    }

    fn compare_images(
        img1: &TensorDyn,
        img2: &TensorDyn,
        threshold: f64,
        name: &str,
    ) -> Result<(), crate::Error> {
        assert_eq!(img1.height(), img2.height(), "Heights differ");
        assert_eq!(img1.width(), img2.width(), "Widths differ");
        assert_eq!(
            img1.format().unwrap(),
            img2.format().unwrap(),
            "PixelFormat differ"
        );
        assert!(
            matches!(img1.format().unwrap(), PixelFormat::Rgb | PixelFormat::Rgba),
            "format must be Rgb or Rgba for comparison"
        );
        let image1 = match img1.format().unwrap() {
            PixelFormat::Rgb => image::RgbImage::from_vec(
                img1.width().unwrap() as u32,
                img1.height().unwrap() as u32,
                img1.as_u8().unwrap().map().unwrap().to_vec(),
            )
            .unwrap(),
            PixelFormat::Rgba => image::RgbaImage::from_vec(
                img1.width().unwrap() as u32,
                img1.height().unwrap() as u32,
                img1.as_u8().unwrap().map().unwrap().to_vec(),
            )
            .unwrap()
            .convert(),

            _ => unreachable!(),
        };

        let image2 = match img2.format().unwrap() {
            PixelFormat::Rgb => image::RgbImage::from_vec(
                img2.width().unwrap() as u32,
                img2.height().unwrap() as u32,
                img2.as_u8().unwrap().map().unwrap().to_vec(),
            )
            .unwrap(),
            PixelFormat::Rgba => image::RgbaImage::from_vec(
                img2.width().unwrap() as u32,
                img2.height().unwrap() as u32,
                img2.as_u8().unwrap().map().unwrap().to_vec(),
            )
            .unwrap()
            .convert(),

            _ => unreachable!(),
        };

        let similarity = image_compare::rgb_similarity_structure(
            &image_compare::Algorithm::RootMeanSquared,
            &image1,
            &image2,
        )
        .expect("Image Comparison failed");

        if similarity.score < threshold {
            image1.save(format!("{name}_1.png")).unwrap();
            image2.save(format!("{name}_2.png")).unwrap();
            return Err(Error::Internal(format!(
                "{name}: converted image and target image have similarity score too low: {} < {}",
                similarity.score, threshold
            )));
        }
        Ok(())
    }

    // =========================================================================
    // PixelFormat::Nv12 Reference Validation Tests
    // These tests compare G2D PixelFormat::Nv12 conversions against ffmpeg-generated references
    // =========================================================================

    fn load_raw_image(
        width: usize,
        height: usize,
        format: PixelFormat,
        memory: Option<TensorMemory>,
        bytes: &[u8],
    ) -> Result<TensorDyn, crate::Error> {
        let img = TensorDyn::image(width, height, format, DType::U8, memory)?;
        let mut map = img.as_u8().unwrap().map()?;
        map.as_mut_slice()[..bytes.len()].copy_from_slice(bytes);
        Ok(img)
    }

    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgba conversion against ffmpeg reference
    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_nv12_to_rgba_reference() -> Result<(), crate::Error> {
        if !is_dma_available() {
            return Ok(());
        }
        // Load PixelFormat::Nv12 source
        let src = load_raw_image(
            1280,
            720,
            PixelFormat::Nv12,
            Some(TensorMemory::Dma),
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.nv12"
            )),
        )?;

        // Load PixelFormat::Rgba reference (ffmpeg-generated)
        let reference = load_raw_image(
            1280,
            720,
            PixelFormat::Rgba,
            None,
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.rgba"
            )),
        )?;

        // Convert using G2D
        let mut dst = TensorDyn::image(
            1280,
            720,
            PixelFormat::Rgba,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let mut g2d = G2DProcessor::new()?;
        let src_dyn = src;
        let mut dst_dyn = dst;
        g2d.convert(
            &src_dyn,
            &mut dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        dst = {
            let mut __t = dst_dyn.into_u8().unwrap();
            __t.set_format(PixelFormat::Rgba)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Copy to CPU for comparison
        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
        cpu_dst
            .as_u8()
            .unwrap()
            .map()?
            .as_mut_slice()
            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());

        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgba_reference")
    }

    /// Test G2D PixelFormat::Nv12→PixelFormat::Rgb conversion against ffmpeg reference
    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_nv12_to_rgb_reference() -> Result<(), crate::Error> {
        if !is_dma_available() {
            return Ok(());
        }
        // Load PixelFormat::Nv12 source
        let src = load_raw_image(
            1280,
            720,
            PixelFormat::Nv12,
            Some(TensorMemory::Dma),
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.nv12"
            )),
        )?;

        // Load PixelFormat::Rgb reference (ffmpeg-generated)
        let reference = load_raw_image(
            1280,
            720,
            PixelFormat::Rgb,
            None,
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.rgb"
            )),
        )?;

        // Convert using G2D
        let mut dst = TensorDyn::image(
            1280,
            720,
            PixelFormat::Rgb,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let mut g2d = G2DProcessor::new()?;
        let src_dyn = src;
        let mut dst_dyn = dst;
        g2d.convert(
            &src_dyn,
            &mut dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        dst = {
            let mut __t = dst_dyn.into_u8().unwrap();
            __t.set_format(PixelFormat::Rgb)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Copy to CPU for comparison
        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
        cpu_dst
            .as_u8()
            .unwrap()
            .map()?
            .as_mut_slice()
            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());

        compare_images(&reference, &cpu_dst, 0.98, "g2d_nv12_to_rgb_reference")
    }

    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgba conversion against ffmpeg reference
    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_yuyv_to_rgba_reference() -> Result<(), crate::Error> {
        if !is_dma_available() {
            return Ok(());
        }
        // Load PixelFormat::Yuyv source
        let src = load_raw_image(
            1280,
            720,
            PixelFormat::Yuyv,
            Some(TensorMemory::Dma),
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.yuyv"
            )),
        )?;

        // Load PixelFormat::Rgba reference (ffmpeg-generated)
        let reference = load_raw_image(
            1280,
            720,
            PixelFormat::Rgba,
            None,
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.rgba"
            )),
        )?;

        // Convert using G2D
        let mut dst = TensorDyn::image(
            1280,
            720,
            PixelFormat::Rgba,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let mut g2d = G2DProcessor::new()?;
        let src_dyn = src;
        let mut dst_dyn = dst;
        g2d.convert(
            &src_dyn,
            &mut dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        dst = {
            let mut __t = dst_dyn.into_u8().unwrap();
            __t.set_format(PixelFormat::Rgba)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Copy to CPU for comparison
        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
        cpu_dst
            .as_u8()
            .unwrap()
            .map()?
            .as_mut_slice()
            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());

        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgba_reference")
    }

    /// Test G2D PixelFormat::Yuyv→PixelFormat::Rgb conversion against ffmpeg reference
    #[test]
    #[cfg(target_os = "linux")]
    fn test_g2d_yuyv_to_rgb_reference() -> Result<(), crate::Error> {
        if !is_dma_available() {
            return Ok(());
        }
        // Load PixelFormat::Yuyv source
        let src = load_raw_image(
            1280,
            720,
            PixelFormat::Yuyv,
            Some(TensorMemory::Dma),
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.yuyv"
            )),
        )?;

        // Load PixelFormat::Rgb reference (ffmpeg-generated)
        let reference = load_raw_image(
            1280,
            720,
            PixelFormat::Rgb,
            None,
            include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/camera720p.rgb"
            )),
        )?;

        // Convert using G2D
        let mut dst = TensorDyn::image(
            1280,
            720,
            PixelFormat::Rgb,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let mut g2d = G2DProcessor::new()?;
        let src_dyn = src;
        let mut dst_dyn = dst;
        g2d.convert(
            &src_dyn,
            &mut dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        dst = {
            let mut __t = dst_dyn.into_u8().unwrap();
            __t.set_format(PixelFormat::Rgb)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Copy to CPU for comparison
        let cpu_dst = TensorDyn::image(1280, 720, PixelFormat::Rgb, DType::U8, None)?;
        cpu_dst
            .as_u8()
            .unwrap()
            .map()?
            .as_mut_slice()
            .copy_from_slice(dst.as_u8().unwrap().map()?.as_slice());

        compare_images(&reference, &cpu_dst, 0.98, "g2d_yuyv_to_rgb_reference")
    }

    /// Test G2D native PixelFormat::Bgra conversion for all supported source formats.
    /// Compares G2D src→PixelFormat::Bgra against G2D src→PixelFormat::Rgba by verifying R↔B swap.
    #[test]
    #[cfg(target_os = "linux")]
    #[ignore = "G2D on i.MX 8MP rejects BGRA as destination format; re-enable when supported"]
    fn test_g2d_bgra_no_resize() {
        for src_fmt in [
            PixelFormat::Rgba,
            PixelFormat::Yuyv,
            PixelFormat::Nv12,
            PixelFormat::Bgra,
        ] {
            test_g2d_bgra_no_resize_(src_fmt).unwrap_or_else(|e| {
                panic!("{src_fmt} to PixelFormat::Bgra failed: {e:?}");
            });
        }
    }

    fn test_g2d_bgra_no_resize_(g2d_in_fmt: PixelFormat) -> Result<(), crate::Error> {
        let file = include_bytes!(concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../testdata/zidane.jpg"
        ))
        .to_vec();
        let src = crate::load_image(&file, Some(PixelFormat::Rgb), None)?;

        // Create DMA buffer for G2D input
        let mut src2 = TensorDyn::image(1280, 720, g2d_in_fmt, DType::U8, Some(TensorMemory::Dma))?;
        let mut cpu_converter = CPUProcessor::new();

        if g2d_in_fmt == PixelFormat::Nv12 {
            let nv12_bytes = include_bytes!(concat!(
                env!("CARGO_MANIFEST_DIR"),
                "/../../testdata/zidane.nv12"
            ));
            src2.as_u8()
                .unwrap()
                .map()?
                .as_mut_slice()
                .copy_from_slice(nv12_bytes);
        } else {
            cpu_converter.convert(&src, &mut src2, Rotation::None, Flip::None, Crop::no_crop())?;
        }

        let mut g2d = G2DProcessor::new()?;

        // Convert to PixelFormat::Bgra via G2D
        let mut bgra_dst = TensorDyn::image(
            1280,
            720,
            PixelFormat::Bgra,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let src2_dyn = src2;
        let mut bgra_dst_dyn = bgra_dst;
        g2d.convert(
            &src2_dyn,
            &mut bgra_dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        bgra_dst = {
            let mut __t = bgra_dst_dyn.into_u8().unwrap();
            __t.set_format(PixelFormat::Bgra)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Reconstruct src2 from dyn for PixelFormat::Rgba conversion
        let src2 = {
            let mut __t = src2_dyn.into_u8().unwrap();
            __t.set_format(g2d_in_fmt)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Convert to PixelFormat::Rgba via G2D as reference
        let mut rgba_dst = TensorDyn::image(
            1280,
            720,
            PixelFormat::Rgba,
            DType::U8,
            Some(TensorMemory::Dma),
        )?;
        let src2_dyn2 = src2;
        let mut rgba_dst_dyn = rgba_dst;
        g2d.convert(
            &src2_dyn2,
            &mut rgba_dst_dyn,
            Rotation::None,
            Flip::None,
            Crop::no_crop(),
        )?;
        rgba_dst = {
            let mut __t = rgba_dst_dyn.into_u8().unwrap();
            __t.set_format(PixelFormat::Rgba)
                .map_err(|e| crate::Error::Internal(e.to_string()))?;
            TensorDyn::from(__t)
        };

        // Copy both to CPU memory for comparison
        let bgra_cpu = TensorDyn::image(1280, 720, PixelFormat::Bgra, DType::U8, None)?;
        bgra_cpu
            .as_u8()
            .unwrap()
            .map()?
            .as_mut_slice()
            .copy_from_slice(bgra_dst.as_u8().unwrap().map()?.as_slice());

        let rgba_cpu = TensorDyn::image(1280, 720, PixelFormat::Rgba, DType::U8, None)?;
        rgba_cpu
            .as_u8()
            .unwrap()
            .map()?
            .as_mut_slice()
            .copy_from_slice(rgba_dst.as_u8().unwrap().map()?.as_slice());

        // Verify PixelFormat::Bgra output has R↔B swapped vs PixelFormat::Rgba output
        let bgra_map = bgra_cpu.as_u8().unwrap().map()?;
        let rgba_map = rgba_cpu.as_u8().unwrap().map()?;
        let bgra_buf = bgra_map.as_slice();
        let rgba_buf = rgba_map.as_slice();

        assert_eq!(bgra_buf.len(), rgba_buf.len());
        for (i, (bc, rc)) in bgra_buf
            .chunks_exact(4)
            .zip(rgba_buf.chunks_exact(4))
            .enumerate()
        {
            assert_eq!(
                bc[0], rc[2],
                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} B mismatch",
            );
            assert_eq!(
                bc[1], rc[1],
                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} G mismatch",
            );
            assert_eq!(
                bc[2], rc[0],
                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} R mismatch",
            );
            assert_eq!(
                bc[3], rc[3],
                "{g2d_in_fmt} to PixelFormat::Bgra: pixel {i} A mismatch",
            );
        }
        Ok(())
    }

    // =========================================================================
    // tensor_to_g2d_surface offset & stride unit tests
    //
    // These tests verify that plane_offset and effective_row_stride are
    // correctly propagated into the G2DSurface.  They require DMA memory
    // but do NOT require G2D hardware — only the DMA_BUF_IOCTL_PHYS ioctl.
    // =========================================================================

    /// Helper: build a DMA-backed Tensor<u8> with an optional plane_offset
    /// and an optional row stride, then return the G2DSurface.
    fn surface_for(
        width: usize,
        height: usize,
        fmt: PixelFormat,
        offset: Option<usize>,
        row_stride: Option<usize>,
    ) -> Result<G2DSurface, crate::Error> {
        use edgefirst_tensor::TensorMemory;
        let mut t = Tensor::<u8>::image(width, height, fmt, Some(TensorMemory::Dma))?;
        if let Some(o) = offset {
            t.set_plane_offset(o);
        }
        if let Some(s) = row_stride {
            t.set_row_stride_unchecked(s);
        }
        tensor_to_g2d_surface(&t)
    }

    #[test]
    fn g2d_surface_single_plane_no_offset() {
        if !is_dma_available() {
            return;
        }
        let s = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
        // planes[0] must be non-zero (valid physical address), no offset
        assert_ne!(s.planes[0], 0);
        assert_eq!(s.stride, 640);
    }

    #[test]
    fn g2d_surface_single_plane_with_offset() {
        if !is_dma_available() {
            return;
        }
        use edgefirst_tensor::TensorMemory;
        let mut t =
            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
        let s0 = tensor_to_g2d_surface(&t).unwrap();
        t.set_plane_offset(4096);
        let s1 = tensor_to_g2d_surface(&t).unwrap();
        assert_eq!(s1.planes[0], s0.planes[0] + 4096);
    }

    #[test]
    fn g2d_surface_single_plane_zero_offset() {
        if !is_dma_available() {
            return;
        }
        use edgefirst_tensor::TensorMemory;
        let mut t =
            Tensor::<u8>::image(640, 480, PixelFormat::Rgba, Some(TensorMemory::Dma)).unwrap();
        let s_none = tensor_to_g2d_surface(&t).unwrap();
        t.set_plane_offset(0);
        let s_zero = tensor_to_g2d_surface(&t).unwrap();
        // offset=0 should produce the same address as no offset
        assert_eq!(s_none.planes[0], s_zero.planes[0]);
    }

    #[test]
    fn g2d_surface_stride_rgba() {
        if !is_dma_available() {
            return;
        }
        // Default stride: width in pixels = 640
        let s_default = surface_for(640, 480, PixelFormat::Rgba, None, None).unwrap();
        assert_eq!(s_default.stride, 640);

        // Custom stride: 2816 bytes / 4 channels = 704 pixels
        let s_custom = surface_for(640, 480, PixelFormat::Rgba, None, Some(2816)).unwrap();
        assert_eq!(s_custom.stride, 704);
    }

    #[test]
    fn g2d_surface_stride_rgb() {
        if !is_dma_available() {
            return;
        }
        let s_default = surface_for(640, 480, PixelFormat::Rgb, None, None).unwrap();
        assert_eq!(s_default.stride, 640);

        // Padded: 1980 bytes / 3 channels = 660 pixels
        let s_custom = surface_for(640, 480, PixelFormat::Rgb, None, Some(1980)).unwrap();
        assert_eq!(s_custom.stride, 660);
    }

    #[test]
    fn g2d_surface_stride_grey() {
        if !is_dma_available() {
            return;
        }
        // Grey (Y800) may not be supported by all G2D hardware versions
        let s = match surface_for(640, 480, PixelFormat::Grey, None, Some(1024)) {
            Ok(s) => s,
            Err(crate::Error::G2D(..)) => return,
            Err(e) => panic!("unexpected error: {e:?}"),
        };
        // Grey: 1 channel. stride in bytes = stride in pixels
        assert_eq!(s.stride, 1024);
    }

    #[test]
    fn g2d_surface_contiguous_nv12_offset() {
        if !is_dma_available() {
            return;
        }
        use edgefirst_tensor::TensorMemory;
        let mut t =
            Tensor::<u8>::image(640, 480, PixelFormat::Nv12, Some(TensorMemory::Dma)).unwrap();
        let s0 = tensor_to_g2d_surface(&t).unwrap();

        t.set_plane_offset(8192);
        let s1 = tensor_to_g2d_surface(&t).unwrap();

        // Luma plane should shift by offset
        assert_eq!(s1.planes[0], s0.planes[0] + 8192);
        // UV plane = base + offset + stride * height
        // Without offset: UV = base + 640 * 480 = base + 307200
        // With offset 8192: UV = base + 8192 + 640 * 480 = base + 315392
        assert_eq!(s1.planes[1], s0.planes[1] + 8192);
    }

    #[test]
    fn g2d_surface_contiguous_nv12_stride() {
        if !is_dma_available() {
            return;
        }
        // NV12: 1 byte per pixel for Y. stride 640 bytes = 640 pixels.
        let s = surface_for(640, 480, PixelFormat::Nv12, None, None).unwrap();
        assert_eq!(s.stride, 640);

        // Padded stride: 1024 bytes = 1024 pixels (NV12 channels = 1)
        let s_padded = surface_for(640, 480, PixelFormat::Nv12, None, Some(1024)).unwrap();
        assert_eq!(s_padded.stride, 1024);
    }

    #[test]
    fn g2d_surface_multiplane_nv12_offset() {
        if !is_dma_available() {
            return;
        }
        use edgefirst_tensor::TensorMemory;

        // Create luma and chroma as separate DMA tensors
        let mut luma =
            Tensor::<u8>::new(&[480, 640], Some(TensorMemory::Dma), Some("luma")).unwrap();
        let mut chroma =
            Tensor::<u8>::new(&[240, 640], Some(TensorMemory::Dma), Some("chroma")).unwrap();

        // Get baseline physical addresses with no offsets
        let luma_base = {
            let dma = luma.as_dma().unwrap();
            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
            phys.address()
        };
        let chroma_base = {
            let dma = chroma.as_dma().unwrap();
            let phys: G2DPhysical = dma.fd.as_raw_fd().try_into().unwrap();
            phys.address()
        };

        // Set offsets and build multiplane tensor
        luma.set_plane_offset(4096);
        chroma.set_plane_offset(2048);
        let combined = Tensor::<u8>::from_planes(luma, chroma, PixelFormat::Nv12).unwrap();
        let s = tensor_to_g2d_surface(&combined).unwrap();

        // Luma should include its offset
        assert_eq!(s.planes[0], luma_base + 4096);
        // Chroma should include its offset
        assert_eq!(s.planes[1], chroma_base + 2048);
    }
}
