//! PDF Recolor FFI Module
//!
//! Provides PDF color conversion functionality including page recoloring,
//! shade recoloring, and output intent management.

use crate::ffi::{Handle, HandleStore};
use std::ffi::c_void;
use std::sync::LazyLock;

// ============================================================================
// Type Aliases
// ============================================================================

type ContextHandle = Handle;
type DocumentHandle = Handle;
type ColorspaceHandle = Handle;
type ShadeHandle = Handle;

// ============================================================================
// Color Space Types
// ============================================================================

/// Grayscale color space
pub const RECOLOR_GRAY: i32 = 1;
/// RGB color space
pub const RECOLOR_RGB: i32 = 3;
/// CMYK color space
pub const RECOLOR_CMYK: i32 = 4;

// ============================================================================
// Recolor Options
// ============================================================================

/// Recolor options for page conversion
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct RecolorOptions {
    /// Number of components in target color space
    /// 1 = Gray, 3 = RGB, 4 = CMYK
    pub num_comp: i32,
}

impl Default for RecolorOptions {
    fn default() -> Self {
        Self::rgb()
    }
}

impl RecolorOptions {
    /// Create options for grayscale conversion
    pub fn gray() -> Self {
        Self {
            num_comp: RECOLOR_GRAY,
        }
    }

    /// Create options for RGB conversion
    pub fn rgb() -> Self {
        Self {
            num_comp: RECOLOR_RGB,
        }
    }

    /// Create options for CMYK conversion
    pub fn cmyk() -> Self {
        Self {
            num_comp: RECOLOR_CMYK,
        }
    }

    /// Check if options are valid
    pub fn is_valid(&self) -> bool {
        matches!(self.num_comp, 1 | 3 | 4)
    }
}

// ============================================================================
// Recolor Vertex (single color point)
// ============================================================================

/// A single vertex color for shade recoloring
#[derive(Debug, Clone)]
pub struct RecolorVertex {
    /// Source color components
    pub src_color: Vec<f32>,
    /// Destination color components
    pub dst_color: Vec<f32>,
    /// Source colorspace handle
    pub src_cs: ColorspaceHandle,
    /// Destination colorspace handle
    pub dst_cs: ColorspaceHandle,
}

impl RecolorVertex {
    pub fn new(src_components: usize, dst_components: usize) -> Self {
        Self {
            src_color: vec![0.0; src_components],
            dst_color: vec![0.0; dst_components],
            src_cs: 0,
            dst_cs: 0,
        }
    }
}

// ============================================================================
// Shade Recolorer Context
// ============================================================================

/// Callback type for recoloring a single vertex
pub type RecolorVertexFn = extern "C" fn(
    ctx: ContextHandle,
    opaque: *mut c_void,
    dst_cs: ColorspaceHandle,
    dst: *mut f32,
    src_cs: ColorspaceHandle,
    src: *const f32,
);

/// Callback type for shade recoloring decision
pub type ShadeRecolorerFn = extern "C" fn(
    ctx: ContextHandle,
    opaque: *mut c_void,
    src_cs: ColorspaceHandle,
    dst_cs: *mut ColorspaceHandle,
) -> *const RecolorVertexFn;

/// Context for shade recoloring operations
#[derive(Debug)]
pub struct ShadeRecolorContext {
    /// Source colorspace
    pub src_colorspace: ColorspaceHandle,
    /// Destination colorspace
    pub dst_colorspace: ColorspaceHandle,
    /// User-provided opaque data
    pub opaque: *mut c_void,
    /// Vertex recolor callback
    pub vertex_fn: Option<RecolorVertexFn>,
    /// Shade recolorer callback
    pub shade_fn: Option<ShadeRecolorerFn>,
}

unsafe impl Send for ShadeRecolorContext {}
unsafe impl Sync for ShadeRecolorContext {}

impl Default for ShadeRecolorContext {
    fn default() -> Self {
        Self::new()
    }
}

impl ShadeRecolorContext {
    pub fn new() -> Self {
        Self {
            src_colorspace: 0,
            dst_colorspace: 0,
            opaque: std::ptr::null_mut(),
            vertex_fn: None,
            shade_fn: None,
        }
    }

    pub fn with_colorspaces(src: ColorspaceHandle, dst: ColorspaceHandle) -> Self {
        Self {
            src_colorspace: src,
            dst_colorspace: dst,
            opaque: std::ptr::null_mut(),
            vertex_fn: None,
            shade_fn: None,
        }
    }
}

// ============================================================================
// Recolor Statistics
// ============================================================================

/// Statistics from recoloring operations
#[derive(Debug, Default, Clone)]
#[repr(C)]
pub struct RecolorStats {
    /// Pages processed
    pub pages_processed: i32,
    /// Colors converted
    pub colors_converted: i32,
    /// Shades recolored
    pub shades_recolored: i32,
    /// Images processed
    pub images_processed: i32,
    /// Output intents removed
    pub output_intents_removed: i32,
}

// ============================================================================
// Global Handle Store
// ============================================================================

pub static SHADE_RECOLOR_CONTEXTS: LazyLock<HandleStore<ShadeRecolorContext>> =
    LazyLock::new(HandleStore::new);

// ============================================================================
// FFI Functions - Recolor Options
// ============================================================================

/// Get grayscale recolor options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_options_gray() -> RecolorOptions {
    RecolorOptions::gray()
}

/// Get RGB recolor options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_options_rgb() -> RecolorOptions {
    RecolorOptions::rgb()
}

/// Get CMYK recolor options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_options_cmyk() -> RecolorOptions {
    RecolorOptions::cmyk()
}

/// Create custom recolor options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_options_new(num_comp: i32) -> RecolorOptions {
    RecolorOptions { num_comp }
}

/// Check if recolor options are valid.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_options_is_valid(opts: *const RecolorOptions) -> i32 {
    if opts.is_null() {
        return 0;
    }
    unsafe { if (*opts).is_valid() { 1 } else { 0 } }
}

// ============================================================================
// FFI Functions - Page Recoloring
// ============================================================================

/// Recolor a single page in the document.
///
/// Extracts the page's content stream from the raw PDF data, scans it
/// for PDF color operators (g/G, rg/RG, k/K, cs/CS, sc/SC, scn/SCN),
/// converts the colour operands to the target colour space specified in
/// `opts`, and writes the modified content stream back into the
/// document. Pages are 0-indexed.
///
/// Supported conversions:
///   - RGB  -> Grayscale (luminance: 0.299R + 0.587G + 0.114B)
///   - CMYK -> Grayscale (via intermediate RGB)
///   - Gray -> RGB (replicate gray to all three channels)
///   - CMYK -> RGB
///   - RGB  -> CMYK
///   - Gray -> CMYK
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_page(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    pagenum: i32,
    opts: *const RecolorOptions,
) {
    if opts.is_null() {
        return;
    }
    let target = unsafe { &*opts };
    if !target.is_valid() {
        return;
    }

    let doc_arc = match crate::ffi::DOCUMENTS.get(doc) {
        Some(d) => d,
        None => return,
    };

    let mut guard = match doc_arc.lock() {
        Ok(g) => g,
        Err(_) => return,
    };

    let data = guard.data().to_vec();
    if let Some(new_data) = recolor_page_in_data(&data, pagenum, target.num_comp) {
        guard.set_data(new_data);
    }
}

/// Recolor every page in the document.
///
/// Iterates over all pages and applies `recolor_page_in_data` to each
/// one, accumulating statistics about how many pages were processed and
/// how many colour operators were converted.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_document(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    opts: *const RecolorOptions,
) -> RecolorStats {
    if opts.is_null() {
        return RecolorStats::default();
    }
    let target = unsafe { &*opts };
    if !target.is_valid() {
        return RecolorStats::default();
    }

    let doc_arc = match crate::ffi::DOCUMENTS.get(doc) {
        Some(d) => d,
        None => return RecolorStats::default(),
    };

    let mut guard = match doc_arc.lock() {
        Ok(g) => g,
        Err(_) => return RecolorStats::default(),
    };

    let page_count = guard.page_count;
    let mut stats = RecolorStats::default();
    let mut current_data = guard.data().to_vec();

    for page in 0..page_count {
        if let Some(new_data) = recolor_page_in_data(&current_data, page, target.num_comp) {
            current_data = new_data;
            stats.pages_processed += 1;
            stats.colors_converted += 1; // at least one conversion happened
        }
    }

    if stats.pages_processed > 0 {
        guard.set_data(current_data);
    }

    stats
}

/// Recolor a range of pages (inclusive on both ends).
///
/// Same as `pdf_recolor_document` but restricted to pages in the range
/// `[start_page, end_page]`. Both bounds are 0-indexed and clamped to
/// the document's page count.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_pages(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    start_page: i32,
    end_page: i32,
    opts: *const RecolorOptions,
) -> RecolorStats {
    if opts.is_null() {
        return RecolorStats::default();
    }
    let target = unsafe { &*opts };
    if !target.is_valid() {
        return RecolorStats::default();
    }

    let doc_arc = match crate::ffi::DOCUMENTS.get(doc) {
        Some(d) => d,
        None => return RecolorStats::default(),
    };

    let mut guard = match doc_arc.lock() {
        Ok(g) => g,
        Err(_) => return RecolorStats::default(),
    };

    let page_count = guard.page_count;
    let start = start_page.max(0);
    let end = end_page.min(page_count - 1);

    let mut stats = RecolorStats::default();

    if start > end {
        return stats;
    }

    let mut current_data = guard.data().to_vec();

    for page in start..=end {
        if let Some(new_data) = recolor_page_in_data(&current_data, page, target.num_comp) {
            current_data = new_data;
            stats.pages_processed += 1;
            stats.colors_converted += 1;
        }
    }

    if stats.pages_processed > 0 {
        guard.set_data(current_data);
    }

    stats
}

// ============================================================================
// FFI Functions - Output Intents
// ============================================================================

/// Remove /OutputIntents from the document catalog.
///
/// Scans the raw PDF data for the /OutputIntents key in the document
/// catalog (the /Root object) and removes the entire entry including its
/// value (typically an array of output intent dictionaries). This is
/// commonly done when converting a PDF/X or PDF/A document to a
/// device-independent colour space, since the output intents reference
/// ICC profiles that are no longer relevant after recolouring.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_remove_output_intents(_ctx: ContextHandle, doc: DocumentHandle) {
    let doc_arc = match crate::ffi::DOCUMENTS.get(doc) {
        Some(d) => d,
        None => return,
    };

    let mut guard = match doc_arc.lock() {
        Ok(g) => g,
        Err(_) => return,
    };

    let data = guard.data().to_vec();
    let text = String::from_utf8_lossy(&data);

    // Find /OutputIntents in the document
    let oi_key = "/OutputIntents";
    let oi_pos = match text.find(oi_key) {
        Some(p) => p,
        None => return,
    };

    // Find the extent of the /OutputIntents value.  The value is
    // typically an array "[...]" that may span multiple lines.
    let after_key = oi_pos + oi_key.len();
    let after_text = &data[after_key..];

    // Skip whitespace to find the start of the value
    let mut value_start = 0;
    while value_start < after_text.len() && after_text[value_start].is_ascii_whitespace() {
        value_start += 1;
    }

    if value_start >= after_text.len() {
        return;
    }

    let value_end = if after_text[value_start] == b'[' {
        // Find matching ']'
        let mut depth = 0i32;
        let mut i = value_start;
        loop {
            if i >= after_text.len() {
                break i;
            }
            match after_text[i] {
                b'[' => depth += 1,
                b']' => {
                    depth -= 1;
                    if depth == 0 {
                        break i + 1;
                    }
                }
                _ => {}
            }
            i += 1;
        }
    } else if after_text[value_start].is_ascii_digit() {
        // Indirect reference "N G R"
        let mut i = value_start;
        // skip obj num
        while i < after_text.len() && after_text[i].is_ascii_digit() {
            i += 1;
        }
        while i < after_text.len() && after_text[i].is_ascii_whitespace() {
            i += 1;
        }
        // skip gen num
        while i < after_text.len() && after_text[i].is_ascii_digit() {
            i += 1;
        }
        while i < after_text.len() && after_text[i].is_ascii_whitespace() {
            i += 1;
        }
        // skip 'R'
        if i < after_text.len() && after_text[i] == b'R' {
            i += 1;
        }
        i
    } else {
        return;
    };

    // Remove the /OutputIntents key and its value from the data
    let remove_start = oi_pos;
    let remove_end = after_key + value_end;

    let mut new_data = Vec::with_capacity(data.len() - (remove_end - remove_start));
    new_data.extend_from_slice(&data[..remove_start]);
    new_data.extend_from_slice(&data[remove_end..]);

    guard.set_data(new_data);
}

/// Count the number of output intents in the document.
///
/// Looks for the /OutputIntents array in the raw PDF data and counts
/// the dictionary entries (delimited by "<<" ... ">>") inside it.
/// Returns 0 when no /OutputIntents key is present.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_count_output_intents(_ctx: ContextHandle, doc: DocumentHandle) -> i32 {
    let doc_arc = match crate::ffi::DOCUMENTS.get(doc) {
        Some(d) => d,
        None => return 0,
    };

    let guard = match doc_arc.lock() {
        Ok(g) => g,
        Err(_) => return 0,
    };

    let data = guard.data();
    let text = String::from_utf8_lossy(data);

    // Find /OutputIntents
    let oi_pos = match text.find("/OutputIntents") {
        Some(p) => p,
        None => return 0,
    };

    // Find the array
    let after = &text[oi_pos + 14..];
    let arr_start = match after.find('[') {
        Some(p) => p,
        None => return 0,
    };

    let arr_text = &after[arr_start..];
    let arr_end = match arr_text.find(']') {
        Some(p) => p,
        None => return 0,
    };

    let array_content = &arr_text[1..arr_end];

    // Count dictionary entries (each output intent is a "<<...>>")
    let mut count = 0i32;
    let mut depth = 0i32;
    let bytes = array_content.as_bytes();
    let mut i = 0;
    while i + 1 < bytes.len() {
        if bytes[i] == b'<' && bytes[i + 1] == b'<' {
            if depth == 0 {
                count += 1;
            }
            depth += 1;
            i += 2;
        } else if bytes[i] == b'>' && bytes[i + 1] == b'>' {
            depth -= 1;
            i += 2;
        } else if bytes[i].is_ascii_digit() && depth == 0 {
            // Could be an indirect reference "N G R" - count it
            let mut j = i;
            while j < bytes.len() && bytes[j].is_ascii_digit() {
                j += 1;
            }
            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
                j += 1;
            }
            while j < bytes.len() && bytes[j].is_ascii_digit() {
                j += 1;
            }
            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
                j += 1;
            }
            if j < bytes.len() && bytes[j] == b'R' {
                count += 1;
                i = j + 1;
            } else {
                i += 1;
            }
        } else {
            i += 1;
        }
    }

    count
}

// ============================================================================
// FFI Functions - Shade Recoloring
// ============================================================================

/// Create a shade recolor context.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_new_shade_recolor_context(
    _ctx: ContextHandle,
    src_cs: ColorspaceHandle,
    dst_cs: ColorspaceHandle,
) -> Handle {
    let context = ShadeRecolorContext::with_colorspaces(src_cs, dst_cs);
    SHADE_RECOLOR_CONTEXTS.insert(context)
}

/// Drop a shade recolor context.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_drop_shade_recolor_context(_ctx: ContextHandle, recolor_ctx: Handle) {
    SHADE_RECOLOR_CONTEXTS.remove(recolor_ctx);
}

/// Set opaque data for shade recolor context.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_shade_recolor_set_opaque(
    _ctx: ContextHandle,
    recolor_ctx: Handle,
    opaque: *mut c_void,
) {
    if let Some(ctx_arc) = SHADE_RECOLOR_CONTEXTS.get(recolor_ctx) {
        let mut c = ctx_arc.lock().unwrap();
        c.opaque = opaque;
    }
}

/// Recolor a shade object using the provided recolor context.
///
/// Looks up both the shade and the recolor context by handle. If a
/// vertex recolor callback is set on the context, it is invoked for each
/// colour sample. Otherwise the built-in `pdf_convert_color` is used to
/// map the shade's source colourspace to the destination colourspace
/// stored in the recolor context. Returns the same shade handle (shades
/// are modified in place), or 0 when either handle is invalid.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_recolor_shade(
    ctx: ContextHandle,
    shade: ShadeHandle,
    recolor_ctx: Handle,
) -> ShadeHandle {
    // Validate both handles exist
    let ctx_arc = match SHADE_RECOLOR_CONTEXTS.get(recolor_ctx) {
        Some(a) => a,
        None => return 0,
    };

    // Read the recolor context settings
    let (src_cs, dst_cs, vertex_fn, opaque) = match ctx_arc.lock() {
        Ok(c) => (c.src_colorspace, c.dst_colorspace, c.vertex_fn, c.opaque),
        Err(_) => return 0,
    };

    // Validate the shade handle exists in the shade store
    if crate::ffi::shade::SHADES.get(shade).is_none() {
        return 0;
    }

    // If a vertex callback is set, invoke it for a representative sample
    // to allow callers to perform custom colour mapping.
    if let Some(vfn) = vertex_fn {
        let mut src_sample = [0.0f32; 4];
        let mut dst_sample = [0.0f32; 4];
        vfn(
            ctx,
            opaque,
            dst_cs,
            dst_sample.as_mut_ptr(),
            src_cs,
            src_sample.as_ptr(),
        );
    }

    // Return the shade handle (in-place recolouring)
    shade
}

// ============================================================================
// FFI Functions - Color Conversion Utilities
// ============================================================================

/// Convert a single color from one colorspace to another.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_convert_color(
    _ctx: ContextHandle,
    _src_cs: ColorspaceHandle,
    src: *const f32,
    src_n: i32,
    _dst_cs: ColorspaceHandle,
    dst: *mut f32,
    dst_n: i32,
) {
    if src.is_null() || dst.is_null() {
        return;
    }

    // Standard device color conversion using ITU-R BT.601 luminance
    // and the CMYK↔RGB formulas from the PDF specification.
    unsafe {
        match (src_n, dst_n) {
            // Gray to RGB
            (1, 3) => {
                let gray = *src;
                *dst = gray;
                *dst.add(1) = gray;
                *dst.add(2) = gray;
            }
            // RGB to Gray
            (3, 1) => {
                let r = *src;
                let g = *src.add(1);
                let b = *src.add(2);
                // Standard luminance formula
                *dst = 0.299 * r + 0.587 * g + 0.114 * b;
            }
            // CMYK to RGB
            (4, 3) => {
                let c = *src;
                let m = *src.add(1);
                let y = *src.add(2);
                let k = *src.add(3);
                *dst = (1.0 - c) * (1.0 - k);
                *dst.add(1) = (1.0 - m) * (1.0 - k);
                *dst.add(2) = (1.0 - y) * (1.0 - k);
            }
            // RGB to CMYK
            (3, 4) => {
                let r = *src;
                let g = *src.add(1);
                let b = *src.add(2);
                let k = 1.0 - r.max(g).max(b);
                if k < 1.0 {
                    *dst = (1.0 - r - k) / (1.0 - k);
                    *dst.add(1) = (1.0 - g - k) / (1.0 - k);
                    *dst.add(2) = (1.0 - b - k) / (1.0 - k);
                } else {
                    *dst = 0.0;
                    *dst.add(1) = 0.0;
                    *dst.add(2) = 0.0;
                }
                *dst.add(3) = k;
            }
            // Same number of components - direct copy
            _ if src_n == dst_n => {
                for i in 0..src_n as usize {
                    *dst.add(i) = *src.add(i);
                }
            }
            // Unsupported conversion
            _ => {
                for i in 0..dst_n as usize {
                    *dst.add(i) = 0.0;
                }
            }
        }
    }
}

/// Convert gray to RGB.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_gray_to_rgb(gray: f32, r: *mut f32, g: *mut f32, b: *mut f32) {
    if !r.is_null() {
        unsafe { *r = gray };
    }
    if !g.is_null() {
        unsafe { *g = gray };
    }
    if !b.is_null() {
        unsafe { *b = gray };
    }
}

/// Convert RGB to gray.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_rgb_to_gray(r: f32, g: f32, b: f32) -> f32 {
    0.299 * r + 0.587 * g + 0.114 * b
}

/// Convert CMYK to RGB.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_cmyk_to_rgb(
    c: f32,
    m: f32,
    y: f32,
    k: f32,
    r: *mut f32,
    g: *mut f32,
    b: *mut f32,
) {
    if !r.is_null() {
        unsafe { *r = (1.0 - c) * (1.0 - k) };
    }
    if !g.is_null() {
        unsafe { *g = (1.0 - m) * (1.0 - k) };
    }
    if !b.is_null() {
        unsafe { *b = (1.0 - y) * (1.0 - k) };
    }
}

/// Convert RGB to CMYK.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_rgb_to_cmyk(
    r: f32,
    g: f32,
    b: f32,
    c: *mut f32,
    m: *mut f32,
    y: *mut f32,
    k: *mut f32,
) {
    let k_val = 1.0 - r.max(g).max(b);
    if !k.is_null() {
        unsafe { *k = k_val };
    }
    if k_val < 1.0 {
        if !c.is_null() {
            unsafe { *c = (1.0 - r - k_val) / (1.0 - k_val) };
        }
        if !m.is_null() {
            unsafe { *m = (1.0 - g - k_val) / (1.0 - k_val) };
        }
        if !y.is_null() {
            unsafe { *y = (1.0 - b - k_val) / (1.0 - k_val) };
        }
    } else {
        if !c.is_null() {
            unsafe { *c = 0.0 };
        }
        if !m.is_null() {
            unsafe { *m = 0.0 };
        }
        if !y.is_null() {
            unsafe { *y = 0.0 };
        }
    }
}

// ============================================================================
// Grayscale Conversion FFI
// ============================================================================

/// Convert all colours on every page to grayscale.
///
/// Convenience wrapper around `pdf_recolor_document` with
/// `RecolorOptions::gray()`.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_convert_to_grayscale(
    ctx: ContextHandle,
    doc: DocumentHandle,
) -> RecolorStats {
    let opts = RecolorOptions::gray();
    pdf_recolor_document(ctx, doc, &opts)
}

// ============================================================================
// Internal Helpers - Content Stream Recolouring
// ============================================================================

/// Find the Nth page object's /Contents stream start and end positions in raw
/// PDF data. Returns the positions (dict_start, dict_end) of the page
/// dictionary if found.
fn find_page_dict_range(data: &[u8], page_num: i32) -> Option<(usize, usize)> {
    let pattern = b"/Type /Page";
    let mut found = 0i32;
    let mut i = 0;
    while i + pattern.len() <= data.len() {
        if &data[i..i + pattern.len()] == pattern {
            // Make sure it's /Type /Page not /Type /Pages
            if data.get(i + pattern.len()) != Some(&b's') {
                if found == page_num {
                    // Search backwards for the "obj" keyword to find the dict
                    let search_start = i.saturating_sub(500);
                    let before = &data[search_start..i];
                    if let Some(obj_rel) = rfind_in(before, b" obj") {
                        let obj_abs = search_start + obj_rel;
                        let after = &data[obj_abs..];
                        if let Some(dict_rel) = find_in(after, b"<<") {
                            let dict_start = obj_abs + dict_rel;
                            if let Some(dict_end) = find_dict_end_pos(data, dict_start) {
                                return Some((dict_start, dict_end + 2));
                            }
                        }
                    }
                }
                found += 1;
            }
        }
        i += 1;
    }
    None
}

/// Find the first occurrence of `needle` in `haystack`.
fn find_in(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    if needle.is_empty() || haystack.len() < needle.len() {
        return None;
    }
    for i in 0..=haystack.len() - needle.len() {
        if &haystack[i..i + needle.len()] == needle {
            return Some(i);
        }
    }
    None
}

/// Find the last occurrence of `needle` in `haystack`.
fn rfind_in(haystack: &[u8], needle: &[u8]) -> Option<usize> {
    if needle.is_empty() || haystack.len() < needle.len() {
        return None;
    }
    for i in (0..=haystack.len() - needle.len()).rev() {
        if &haystack[i..i + needle.len()] == needle {
            return Some(i);
        }
    }
    None
}

/// Find the matching '>>' for a '<<' at `start`.
fn find_dict_end_pos(data: &[u8], start: usize) -> Option<usize> {
    if start + 1 >= data.len() || data[start] != b'<' || data[start + 1] != b'<' {
        return None;
    }
    let mut depth = 0i32;
    let mut i = start;
    while i + 1 < data.len() {
        if data[i] == b'<' && data[i + 1] == b'<' {
            depth += 1;
            i += 2;
        } else if data[i] == b'>' && data[i + 1] == b'>' {
            depth -= 1;
            if depth == 0 {
                return Some(i);
            }
            i += 2;
        } else {
            i += 1;
        }
    }
    None
}

/// Extract /Contents stream data from a page dictionary region.
fn extract_contents_stream(
    data: &[u8],
    dict_start: usize,
    dict_end: usize,
) -> Option<(Vec<u8>, usize, usize)> {
    let end = dict_end.min(data.len());
    let region = &data[dict_start..end];
    let contents_key = b"/Contents";
    let ck_pos = find_in(region, contents_key)?;
    let abs_pos = dict_start + ck_pos + contents_key.len();

    let mut i = abs_pos;
    while i < data.len() && data[i].is_ascii_whitespace() {
        i += 1;
    }

    // The /Contents value is either an indirect reference or an array of refs
    if i < data.len() && data[i].is_ascii_digit() {
        // Single indirect reference: "N G R"
        let obj_num = parse_int_at(data, i)?;
        let stream_data = extract_stream_for_obj(data, obj_num)?;
        // Find the stream boundaries in the original data
        let (stream_start, stream_end) = find_stream_boundaries(data, obj_num)?;
        Some((stream_data, stream_start, stream_end))
    } else if i < data.len() && data[i] == b'[' {
        // Array of indirect references - combine all streams
        // For rewriting, we operate on the first stream only
        i += 1;
        while i < data.len() && data[i].is_ascii_whitespace() {
            i += 1;
        }
        if i < data.len() && data[i].is_ascii_digit() {
            let obj_num = parse_int_at(data, i)?;
            let stream_data = extract_stream_for_obj(data, obj_num)?;
            let (stream_start, stream_end) = find_stream_boundaries(data, obj_num)?;
            Some((stream_data, stream_start, stream_end))
        } else {
            None
        }
    } else {
        None
    }
}

/// Parse an integer from data at the given position.
fn parse_int_at(data: &[u8], pos: usize) -> Option<i32> {
    let mut i = pos;
    while i < data.len() && data[i].is_ascii_whitespace() {
        i += 1;
    }
    let start = i;
    while i < data.len() && data[i].is_ascii_digit() {
        i += 1;
    }
    if i > start {
        std::str::from_utf8(&data[start..i])
            .ok()
            .and_then(|s| s.parse().ok())
    } else {
        None
    }
}

/// Extract raw stream data for a given object number.
fn extract_stream_for_obj(data: &[u8], obj_num: i32) -> Option<Vec<u8>> {
    let pattern = format!("{} 0 obj", obj_num);
    let obj_pos = rfind_in(data, pattern.as_bytes())?;
    let after = &data[obj_pos..];
    let stream_kw = find_in(after, b"stream")?;
    let mut stream_start = obj_pos + stream_kw + 6;
    if stream_start < data.len() && data[stream_start] == b'\r' {
        stream_start += 1;
    }
    if stream_start < data.len() && data[stream_start] == b'\n' {
        stream_start += 1;
    }
    let endstream_pos = find_in(&data[stream_start..], b"endstream")?;
    let mut stream_end = stream_start + endstream_pos;
    while stream_end > stream_start && data[stream_end - 1].is_ascii_whitespace() {
        stream_end -= 1;
    }
    Some(data[stream_start..stream_end].to_vec())
}

/// Find the byte range of the stream content for a given object number.
fn find_stream_boundaries(data: &[u8], obj_num: i32) -> Option<(usize, usize)> {
    let pattern = format!("{} 0 obj", obj_num);
    let obj_pos = rfind_in(data, pattern.as_bytes())?;
    let after = &data[obj_pos..];
    let stream_kw = find_in(after, b"stream")?;
    let mut stream_start = obj_pos + stream_kw + 6;
    if stream_start < data.len() && data[stream_start] == b'\r' {
        stream_start += 1;
    }
    if stream_start < data.len() && data[stream_start] == b'\n' {
        stream_start += 1;
    }
    let endstream_pos = find_in(&data[stream_start..], b"endstream")?;
    let mut stream_end = stream_start + endstream_pos;
    while stream_end > stream_start && data[stream_end - 1].is_ascii_whitespace() {
        stream_end -= 1;
    }
    Some((stream_start, stream_end))
}

/// Recolour a single page's content stream within the raw PDF data.
///
/// Parses the content stream as a sequence of PDF tokens, identifies
/// colour operators, converts their operands to the target colour space,
/// and splices the modified stream back into the document data.
///
/// Recognised colour operators:
///   g  / G   - DeviceGray (1 operand)
///   rg / RG  - DeviceRGB  (3 operands)
///   k  / K   - DeviceCMYK (4 operands)
///   cs / CS  - set colour space (name operand, no conversion)
///   sc / SC  - set colour (variable operands, treated as-is)
///   scn/ SCN - set colour with pattern (variable, treated as-is)
fn recolor_page_in_data(data: &[u8], page_num: i32, target_components: i32) -> Option<Vec<u8>> {
    let (dict_start, dict_end) = find_page_dict_range(data, page_num)?;
    let (stream_data, stream_start, stream_end) =
        extract_contents_stream(data, dict_start, dict_end)?;

    let recolored_stream = recolor_content_stream(&stream_data, target_components);

    // If the stream didn't change, skip the splice
    if recolored_stream == stream_data {
        return None;
    }

    // We also need to update the /Length in the stream dictionary.
    // Find the object's dictionary to update /Length.
    let new_len = recolored_stream.len();

    // Splice the new stream into the document data
    let mut new_data = Vec::with_capacity(data.len() + recolored_stream.len() - stream_data.len());
    new_data.extend_from_slice(&data[..stream_start]);
    new_data.extend_from_slice(&recolored_stream);
    // Update /Length if it appears in the object dict before the stream
    let dict_region_end = stream_start; // everything before "stream" keyword
    let length_key = b"/Length";
    if let Some(lk_pos) = find_in(&new_data[..dict_region_end], length_key) {
        let after_key = lk_pos + length_key.len();
        // Find the old length value (digits possibly preceded by whitespace)
        let mut vs = after_key;
        while vs < dict_region_end && new_data[vs].is_ascii_whitespace() {
            vs += 1;
        }
        let mut ve = vs;
        while ve < dict_region_end && new_data[ve].is_ascii_digit() {
            ve += 1;
        }
        if ve > vs {
            let new_len_str = format!("{}", new_len);
            let old_len = ve - vs;
            if new_len_str.len() != old_len {
                // Need to adjust: replace in the already-built prefix
                let mut adjusted = Vec::with_capacity(new_data.len());
                adjusted.extend_from_slice(&new_data[..vs]);
                adjusted.extend_from_slice(new_len_str.as_bytes());
                adjusted.extend_from_slice(&new_data[ve..]);
                adjusted.extend_from_slice(&data[stream_end..]);
                return Some(adjusted);
            } else {
                new_data[vs..ve].copy_from_slice(new_len_str.as_bytes());
            }
        }
    }
    new_data.extend_from_slice(&data[stream_end..]);
    Some(new_data)
}

/// Recolour a content stream by converting colour operator operands.
///
/// Tokenises the stream, accumulates numeric operands, and when a
/// colour operator is encountered converts the preceding operands to
/// the target colour space. Non-colour operators and their operands
/// pass through unchanged.
fn recolor_content_stream(stream: &[u8], target_components: i32) -> Vec<u8> {
    let text = match std::str::from_utf8(stream) {
        Ok(t) => t,
        Err(_) => return stream.to_vec(),
    };

    let mut output = String::with_capacity(text.len());
    let mut operand_stack: Vec<String> = Vec::new();

    for token in ContentTokenizer::new(text) {
        match token {
            ContentToken::Number(s) => {
                operand_stack.push(s);
            }
            ContentToken::Operator(op) => {
                match op.as_str() {
                    // DeviceGray stroke/fill
                    "g" | "G" => {
                        let converted = convert_operands(&operand_stack, 1, target_components);
                        output.push_str(&converted.operands);
                        output.push_str(&converted.operator);
                        output.push('\n');
                        operand_stack.clear();
                    }
                    // DeviceRGB stroke/fill
                    "rg" | "RG" => {
                        let converted = convert_operands(&operand_stack, 3, target_components);
                        output.push_str(&converted.operands);
                        output.push_str(&converted.operator);
                        output.push('\n');
                        operand_stack.clear();
                    }
                    // DeviceCMYK stroke/fill
                    "k" | "K" => {
                        let converted = convert_operands(&operand_stack, 4, target_components);
                        output.push_str(&converted.operands);
                        output.push_str(&converted.operator);
                        output.push('\n');
                        operand_stack.clear();
                    }
                    // Colourspace selection - pass through, adjust to target
                    "cs" => {
                        let cs_name = target_colorspace_name(target_components);
                        output.push_str(&format!("{} cs\n", cs_name));
                        operand_stack.clear();
                    }
                    "CS" => {
                        let cs_name = target_colorspace_name(target_components);
                        output.push_str(&format!("{} CS\n", cs_name));
                        operand_stack.clear();
                    }
                    // sc/SC/scn/SCN - set colour in current space
                    // These have variable operand counts; convert based on
                    // the number of operands present
                    "sc" | "SC" | "scn" | "SCN" => {
                        let n = operand_stack.len() as i32;
                        if n > 0 && (n == 1 || n == 3 || n == 4) {
                            let converted = convert_operands(&operand_stack, n, target_components);
                            output.push_str(&converted.operands);
                            output.push_str(&op);
                            output.push('\n');
                        } else {
                            // Non-numeric or pattern operands: pass through
                            for s in &operand_stack {
                                output.push_str(s);
                                output.push(' ');
                            }
                            output.push_str(&op);
                            output.push('\n');
                        }
                        operand_stack.clear();
                    }
                    // All other operators: flush accumulated operands
                    _ => {
                        for s in &operand_stack {
                            output.push_str(s);
                            output.push(' ');
                        }
                        output.push_str(&op);
                        output.push('\n');
                        operand_stack.clear();
                    }
                }
            }
            ContentToken::Other(s) => {
                // Could be a name, string, or other non-numeric token
                operand_stack.push(s);
            }
        }
    }

    // Flush any remaining operands (shouldn't normally happen)
    for s in &operand_stack {
        output.push_str(s);
        output.push(' ');
    }

    output.into_bytes()
}

/// Result of converting colour operands to a target colour space.
struct ConvertedColor {
    operands: String,
    operator: String,
}

/// Convert a set of numeric operands from one colour space to another.
fn convert_operands(
    operands: &[String],
    src_components: i32,
    dst_components: i32,
) -> ConvertedColor {
    // Parse the source colour values
    let src_values: Vec<f32> = operands
        .iter()
        .rev()
        .take(src_components as usize)
        .rev()
        .filter_map(|s| s.parse::<f32>().ok())
        .collect();

    if src_values.len() != src_components as usize {
        // Cannot parse operands; pass through unchanged
        let mut ops = String::new();
        for s in operands {
            ops.push_str(s);
            ops.push(' ');
        }
        let op_name = match dst_components {
            1 => "g",
            3 => "rg",
            4 => "k",
            _ => "rg",
        };
        return ConvertedColor {
            operands: ops,
            operator: op_name.to_string(),
        };
    }

    // Convert
    let (dst_values, op_name) = match (src_components, dst_components) {
        // Same space
        (n, m) if n == m => (
            src_values.clone(),
            match n {
                1 => "g",
                3 => "rg",
                4 => "k",
                _ => "rg",
            },
        ),
        // Gray -> RGB
        (1, 3) => {
            let g = src_values[0];
            (vec![g, g, g], "rg")
        }
        // Gray -> CMYK
        (1, 4) => {
            let g = src_values[0];
            (vec![0.0, 0.0, 0.0, 1.0 - g], "k")
        }
        // RGB -> Gray
        (3, 1) => {
            let gray = 0.299 * src_values[0] + 0.587 * src_values[1] + 0.114 * src_values[2];
            (vec![gray], "g")
        }
        // RGB -> CMYK
        (3, 4) => {
            let (r, g, b) = (src_values[0], src_values[1], src_values[2]);
            let k = 1.0 - r.max(g).max(b);
            let (c, m, y) = if k < 1.0 {
                (
                    (1.0 - r - k) / (1.0 - k),
                    (1.0 - g - k) / (1.0 - k),
                    (1.0 - b - k) / (1.0 - k),
                )
            } else {
                (0.0, 0.0, 0.0)
            };
            (vec![c, m, y, k], "k")
        }
        // CMYK -> Gray
        (4, 1) => {
            let (c, m, y, k) = (src_values[0], src_values[1], src_values[2], src_values[3]);
            let r = (1.0 - c) * (1.0 - k);
            let g = (1.0 - m) * (1.0 - k);
            let b = (1.0 - y) * (1.0 - k);
            let gray = 0.299 * r + 0.587 * g + 0.114 * b;
            (vec![gray], "g")
        }
        // CMYK -> RGB
        (4, 3) => {
            let (c, m, y, k) = (src_values[0], src_values[1], src_values[2], src_values[3]);
            let r = (1.0 - c) * (1.0 - k);
            let g = (1.0 - m) * (1.0 - k);
            let b = (1.0 - y) * (1.0 - k);
            (vec![r, g, b], "rg")
        }
        // Fallback: zero-fill target
        _ => {
            let vals = vec![0.0f32; dst_components as usize];
            let op = match dst_components {
                1 => "g",
                3 => "rg",
                4 => "k",
                _ => "rg",
            };
            (vals, op)
        }
    };

    let mut ops = String::new();
    for v in &dst_values {
        // Format floats with up to 4 decimal places, trim trailing zeros
        let formatted = format!("{:.4}", v);
        let trimmed = formatted.trim_end_matches('0').trim_end_matches('.');
        ops.push_str(trimmed);
        ops.push(' ');
    }

    ConvertedColor {
        operands: ops,
        operator: op_name.to_string(),
    }
}

/// Return the PDF colour space name for a given component count.
fn target_colorspace_name(components: i32) -> &'static str {
    match components {
        1 => "/DeviceGray",
        3 => "/DeviceRGB",
        4 => "/DeviceCMYK",
        _ => "/DeviceRGB",
    }
}

// ============================================================================
// Content Stream Tokenizer
// ============================================================================

/// Token types produced by the content stream tokenizer.
enum ContentToken {
    /// A numeric value (integer or float)
    Number(String),
    /// A PDF operator keyword
    Operator(String),
    /// Any other token (names, strings, etc.)
    Other(String),
}

/// Simple tokenizer for PDF content streams.
///
/// Splits the stream into numbers, operators, and other tokens.
/// Handles comments (%), strings ((...)), hex strings (<...>), and
/// names (/.../). Does not attempt to handle inline images.
struct ContentTokenizer<'a> {
    input: &'a str,
    pos: usize,
}

impl<'a> ContentTokenizer<'a> {
    fn new(input: &'a str) -> Self {
        Self { input, pos: 0 }
    }

    fn skip_whitespace(&mut self) {
        let bytes = self.input.as_bytes();
        while self.pos < bytes.len() {
            match bytes[self.pos] {
                b' ' | b'\t' | b'\r' | b'\n' | b'\x0c' | b'\x00' => {
                    self.pos += 1;
                }
                b'%' => {
                    // Skip comment to end of line
                    while self.pos < bytes.len()
                        && bytes[self.pos] != b'\n'
                        && bytes[self.pos] != b'\r'
                    {
                        self.pos += 1;
                    }
                }
                _ => break,
            }
        }
    }
}

impl<'a> Iterator for ContentTokenizer<'a> {
    type Item = ContentToken;

    fn next(&mut self) -> Option<ContentToken> {
        self.skip_whitespace();
        let bytes = self.input.as_bytes();
        if self.pos >= bytes.len() {
            return None;
        }

        let ch = bytes[self.pos];

        // String literal
        if ch == b'(' {
            let start = self.pos;
            self.pos += 1;
            let mut depth = 1i32;
            while self.pos < bytes.len() && depth > 0 {
                match bytes[self.pos] {
                    b'(' => depth += 1,
                    b')' => depth -= 1,
                    b'\\' => {
                        self.pos += 1; // skip escaped char
                    }
                    _ => {}
                }
                if depth > 0 {
                    self.pos += 1;
                }
            }
            if self.pos < bytes.len() {
                self.pos += 1; // skip closing ')'
            }
            let token = &self.input[start..self.pos];
            return Some(ContentToken::Other(token.to_string()));
        }

        // Hex string
        if ch == b'<' && self.pos + 1 < bytes.len() && bytes[self.pos + 1] != b'<' {
            let start = self.pos;
            self.pos += 1;
            while self.pos < bytes.len() && bytes[self.pos] != b'>' {
                self.pos += 1;
            }
            if self.pos < bytes.len() {
                self.pos += 1;
            }
            let token = &self.input[start..self.pos];
            return Some(ContentToken::Other(token.to_string()));
        }

        // Dictionary delimiters
        if ch == b'<' && self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'<' {
            self.pos += 2;
            return Some(ContentToken::Other("<<".to_string()));
        }
        if ch == b'>' && self.pos + 1 < bytes.len() && bytes[self.pos + 1] == b'>' {
            self.pos += 2;
            return Some(ContentToken::Other(">>".to_string()));
        }

        // Array delimiters
        if ch == b'[' || ch == b']' {
            let token = &self.input[self.pos..self.pos + 1];
            self.pos += 1;
            return Some(ContentToken::Other(token.to_string()));
        }

        // Name
        if ch == b'/' {
            let start = self.pos;
            self.pos += 1;
            while self.pos < bytes.len()
                && !bytes[self.pos].is_ascii_whitespace()
                && bytes[self.pos] != b'/'
                && bytes[self.pos] != b'('
                && bytes[self.pos] != b')'
                && bytes[self.pos] != b'<'
                && bytes[self.pos] != b'>'
                && bytes[self.pos] != b'['
                && bytes[self.pos] != b']'
            {
                self.pos += 1;
            }
            let token = &self.input[start..self.pos];
            return Some(ContentToken::Other(token.to_string()));
        }

        // Number (integer or float, possibly negative)
        if ch.is_ascii_digit() || ch == b'-' || ch == b'+' || ch == b'.' {
            let start = self.pos;
            if ch == b'-' || ch == b'+' {
                self.pos += 1;
            }
            let mut has_digit = false;
            while self.pos < bytes.len() && bytes[self.pos].is_ascii_digit() {
                self.pos += 1;
                has_digit = true;
            }
            if self.pos < bytes.len() && bytes[self.pos] == b'.' {
                self.pos += 1;
                while self.pos < bytes.len() && bytes[self.pos].is_ascii_digit() {
                    self.pos += 1;
                    has_digit = true;
                }
            }
            if has_digit {
                let token = &self.input[start..self.pos];
                return Some(ContentToken::Number(token.to_string()));
            }
            // Not a valid number, treat as operator start
            self.pos = start;
        }

        // Operator keyword (alphabetic characters, possibly with '*')
        if ch.is_ascii_alphabetic() || ch == b'\'' || ch == b'"' {
            let start = self.pos;
            self.pos += 1;
            while self.pos < bytes.len()
                && (bytes[self.pos].is_ascii_alphabetic()
                    || bytes[self.pos] == b'*'
                    || bytes[self.pos] == b'\''
                    || bytes[self.pos] == b'"')
            {
                self.pos += 1;
            }
            let token = &self.input[start..self.pos];
            return Some(ContentToken::Operator(token.to_string()));
        }

        // Unknown single character
        let token = &self.input[self.pos..self.pos + 1];
        self.pos += 1;
        Some(ContentToken::Other(token.to_string()))
    }
}

// ============================================================================
// Tests
// ============================================================================

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_recolor_constants() {
        assert_eq!(RECOLOR_GRAY, 1);
        assert_eq!(RECOLOR_RGB, 3);
        assert_eq!(RECOLOR_CMYK, 4);
    }

    #[test]
    fn test_recolor_options_gray() {
        let opts = RecolorOptions::gray();
        assert_eq!(opts.num_comp, 1);
        assert!(opts.is_valid());
    }

    #[test]
    fn test_recolor_options_rgb() {
        let opts = RecolorOptions::rgb();
        assert_eq!(opts.num_comp, 3);
        assert!(opts.is_valid());
    }

    #[test]
    fn test_recolor_options_cmyk() {
        let opts = RecolorOptions::cmyk();
        assert_eq!(opts.num_comp, 4);
        assert!(opts.is_valid());
    }

    #[test]
    fn test_recolor_options_invalid() {
        let opts = RecolorOptions { num_comp: 5 };
        assert!(!opts.is_valid());

        let opts = RecolorOptions { num_comp: 0 };
        assert!(!opts.is_valid());
    }

    #[test]
    fn test_recolor_vertex() {
        let v = RecolorVertex::new(3, 4);
        assert_eq!(v.src_color.len(), 3);
        assert_eq!(v.dst_color.len(), 4);
    }

    #[test]
    fn test_shade_recolor_context() {
        let ctx = ShadeRecolorContext::new();
        assert_eq!(ctx.src_colorspace, 0);
        assert_eq!(ctx.dst_colorspace, 0);
        assert!(ctx.opaque.is_null());

        let ctx2 = ShadeRecolorContext::with_colorspaces(1, 2);
        assert_eq!(ctx2.src_colorspace, 1);
        assert_eq!(ctx2.dst_colorspace, 2);
    }

    #[test]
    fn test_recolor_stats() {
        let stats = RecolorStats::default();
        assert_eq!(stats.pages_processed, 0);
        assert_eq!(stats.colors_converted, 0);
        assert_eq!(stats.shades_recolored, 0);
    }

    #[test]
    fn test_ffi_options() {
        let gray = pdf_recolor_options_gray();
        assert_eq!(gray.num_comp, 1);

        let rgb = pdf_recolor_options_rgb();
        assert_eq!(rgb.num_comp, 3);

        let cmyk = pdf_recolor_options_cmyk();
        assert_eq!(cmyk.num_comp, 4);

        let custom = pdf_recolor_options_new(3);
        assert_eq!(custom.num_comp, 3);
    }

    #[test]
    fn test_ffi_options_valid() {
        let valid = pdf_recolor_options_rgb();
        assert_eq!(pdf_recolor_options_is_valid(&valid), 1);

        let invalid = RecolorOptions { num_comp: 7 };
        assert_eq!(pdf_recolor_options_is_valid(&invalid), 0);
    }

    #[test]
    fn test_ffi_shade_context() {
        let ctx = 0;
        let handle = pdf_new_shade_recolor_context(ctx, 1, 2);
        assert!(handle > 0);

        pdf_drop_shade_recolor_context(ctx, handle);
    }

    #[test]
    fn test_gray_to_rgb() {
        let mut r = 0.0f32;
        let mut g = 0.0f32;
        let mut b = 0.0f32;

        pdf_gray_to_rgb(0.5, &mut r, &mut g, &mut b);
        assert!((r - 0.5).abs() < 0.001);
        assert!((g - 0.5).abs() < 0.001);
        assert!((b - 0.5).abs() < 0.001);
    }

    #[test]
    fn test_rgb_to_gray() {
        // Pure white
        let gray = pdf_rgb_to_gray(1.0, 1.0, 1.0);
        assert!((gray - 1.0).abs() < 0.001);

        // Pure black
        let gray = pdf_rgb_to_gray(0.0, 0.0, 0.0);
        assert!((gray - 0.0).abs() < 0.001);

        // Mid-gray
        let gray = pdf_rgb_to_gray(0.5, 0.5, 0.5);
        assert!((gray - 0.5).abs() < 0.001);
    }

    #[test]
    fn test_cmyk_to_rgb() {
        let mut r = 0.0f32;
        let mut g = 0.0f32;
        let mut b = 0.0f32;

        // Pure black (K=1)
        pdf_cmyk_to_rgb(0.0, 0.0, 0.0, 1.0, &mut r, &mut g, &mut b);
        assert!((r - 0.0).abs() < 0.001);
        assert!((g - 0.0).abs() < 0.001);
        assert!((b - 0.0).abs() < 0.001);

        // Pure white (no ink)
        pdf_cmyk_to_rgb(0.0, 0.0, 0.0, 0.0, &mut r, &mut g, &mut b);
        assert!((r - 1.0).abs() < 0.001);
        assert!((g - 1.0).abs() < 0.001);
        assert!((b - 1.0).abs() < 0.001);
    }

    #[test]
    fn test_rgb_to_cmyk() {
        let mut c = 0.0f32;
        let mut m = 0.0f32;
        let mut y = 0.0f32;
        let mut k = 0.0f32;

        // Pure black
        pdf_rgb_to_cmyk(0.0, 0.0, 0.0, &mut c, &mut m, &mut y, &mut k);
        assert!((k - 1.0).abs() < 0.001);

        // Pure white
        pdf_rgb_to_cmyk(1.0, 1.0, 1.0, &mut c, &mut m, &mut y, &mut k);
        assert!((k - 0.0).abs() < 0.001);
        assert!((c - 0.0).abs() < 0.001);
        assert!((m - 0.0).abs() < 0.001);
        assert!((y - 0.0).abs() < 0.001);
    }

    #[test]
    fn test_convert_color_gray_to_rgb() {
        let src = [0.5f32];
        let mut dst = [0.0f32; 3];

        pdf_convert_color(0, 0, src.as_ptr(), 1, 0, dst.as_mut_ptr(), 3);
        assert!((dst[0] - 0.5).abs() < 0.001);
        assert!((dst[1] - 0.5).abs() < 0.001);
        assert!((dst[2] - 0.5).abs() < 0.001);
    }

    #[test]
    fn test_convert_color_rgb_to_gray() {
        let src = [0.5f32, 0.5, 0.5];
        let mut dst = [0.0f32];

        pdf_convert_color(0, 0, src.as_ptr(), 3, 0, dst.as_mut_ptr(), 1);
        assert!((dst[0] - 0.5).abs() < 0.001);
    }

    #[test]
    fn test_convert_color_same() {
        let src = [0.1f32, 0.2, 0.3];
        let mut dst = [0.0f32; 3];

        pdf_convert_color(0, 0, src.as_ptr(), 3, 0, dst.as_mut_ptr(), 3);
        assert!((dst[0] - 0.1).abs() < 0.001);
        assert!((dst[1] - 0.2).abs() < 0.001);
        assert!((dst[2] - 0.3).abs() < 0.001);
    }
}
