//! PDF Redaction FFI Module
//!
//! Provides PDF redaction functionality including redaction annotations,
//! content removal, image handling, and metadata sanitization.
//!
//! Redaction operates on the raw PDF byte data stored in the Document
//! handle.  The approach mirrors how lightweight PDF tooling works:
//!
//! 1. Content-stream text operators (BT..ET) are parsed and any text
//!    whose positioning falls inside a redaction rectangle is removed.
//! 2. Inline and XObject images whose bounding rectangles overlap a
//!    redaction region are removed (or pixel-blacked, depending on
//!    options).
//! 3. Line-art path operators are filtered when they fall inside or
//!    are touched by a redaction rectangle.
//! 4. An optional black-box overlay is drawn for each redacted region.
//! 5. Metadata sanitization strips Info-dictionary entries, XMP
//!    metadata streams, embedded JavaScript, file attachments, and
//!    comment annotations by rewriting the raw PDF bytes.

use crate::ffi::annot::ANNOTATIONS;
use crate::ffi::document::{Document, PAGES};
use crate::ffi::{DOCUMENTS, Handle, HandleStore};
use crate::fitz::geometry::Rect;
use crate::pdf::annot::{AnnotType, Annotation};
use std::sync::LazyLock;

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

type ContextHandle = Handle;
type DocumentHandle = Handle;
type PageHandle = Handle;
type AnnotHandle = Handle;

// ============================================================================
// Image Redaction Methods
// ============================================================================

/// Do not change images at all
pub const PDF_REDACT_IMAGE_NONE: i32 = 0;
/// Remove image if it intrudes across redaction region
pub const PDF_REDACT_IMAGE_REMOVE: i32 = 1;
/// Replace intruding image pixels with black
pub const PDF_REDACT_IMAGE_PIXELS: i32 = 2;
/// Remove image unless it's invisible in redaction region
pub const PDF_REDACT_IMAGE_REMOVE_UNLESS_INVISIBLE: i32 = 3;

// ============================================================================
// Line Art Redaction Methods
// ============================================================================

/// Do not change line art
pub const PDF_REDACT_LINE_ART_NONE: i32 = 0;
/// Remove line art if fully covered by redaction
pub const PDF_REDACT_LINE_ART_REMOVE_IF_COVERED: i32 = 1;
/// Remove line art if touched by redaction
pub const PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED: i32 = 2;

// ============================================================================
// Text Redaction Methods
// ============================================================================

/// Remove any text that overlaps with redaction (secure, default)
pub const PDF_REDACT_TEXT_REMOVE: i32 = 0;
/// Do not remove any text (INSECURE)
pub const PDF_REDACT_TEXT_NONE: i32 = 1;
/// Remove only invisible text (for OCR layers)
pub const PDF_REDACT_TEXT_REMOVE_INVISIBLE: i32 = 2;

// ============================================================================
// Redaction Options
// ============================================================================

/// Redaction options
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct RedactOptions {
    /// Draw black boxes over redacted areas
    pub black_boxes: i32,
    /// Image handling method
    pub image_method: i32,
    /// Line art handling method
    pub line_art: i32,
    /// Text handling method
    pub text: i32,
}

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

impl RedactOptions {
    pub fn new() -> Self {
        Self {
            black_boxes: 1,
            image_method: PDF_REDACT_IMAGE_REMOVE,
            line_art: PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED,
            text: PDF_REDACT_TEXT_REMOVE,
        }
    }

    /// Create secure redaction options (most aggressive)
    pub fn secure() -> Self {
        Self {
            black_boxes: 1,
            image_method: PDF_REDACT_IMAGE_REMOVE,
            line_art: PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED,
            text: PDF_REDACT_TEXT_REMOVE,
        }
    }

    /// Create options for OCR text layer removal only
    pub fn ocr_only() -> Self {
        Self {
            black_boxes: 0,
            image_method: PDF_REDACT_IMAGE_NONE,
            line_art: PDF_REDACT_LINE_ART_NONE,
            text: PDF_REDACT_TEXT_REMOVE_INVISIBLE,
        }
    }

    /// Create options that preserve visible content
    pub fn preserve_visible() -> Self {
        Self {
            black_boxes: 0,
            image_method: PDF_REDACT_IMAGE_PIXELS,
            line_art: PDF_REDACT_LINE_ART_REMOVE_IF_COVERED,
            text: PDF_REDACT_TEXT_REMOVE,
        }
    }
}

// ============================================================================
// Redaction Region
// ============================================================================

/// A region to be redacted
#[derive(Debug, Clone)]
pub struct RedactRegion {
    /// Bounding box [x0, y0, x1, y1]
    pub rect: [f32; 4],
    /// Overlay color [r, g, b] (0.0-1.0)
    pub color: [f32; 3],
    /// Overlay text (optional)
    pub overlay_text: Option<String>,
    /// Applied flag
    pub applied: bool,
}

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

impl RedactRegion {
    pub fn new() -> Self {
        Self {
            rect: [0.0, 0.0, 0.0, 0.0],
            color: [0.0, 0.0, 0.0], // Black
            overlay_text: None,
            applied: false,
        }
    }

    pub fn with_rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Self {
        Self {
            rect: [x0, y0, x1, y1],
            color: [0.0, 0.0, 0.0],
            overlay_text: None,
            applied: false,
        }
    }

    pub fn with_color(mut self, r: f32, g: f32, b: f32) -> Self {
        self.color = [r, g, b];
        self
    }

    pub fn with_overlay(mut self, text: &str) -> Self {
        self.overlay_text = Some(text.to_string());
        self
    }

    /// Convert to a geometry Rect for intersection tests
    fn to_rect(&self) -> Rect {
        Rect::new(self.rect[0], self.rect[1], self.rect[2], self.rect[3])
    }
}

// ============================================================================
// Redaction Context
// ============================================================================

/// Redaction context for a page
#[derive(Debug, Default)]
pub struct RedactContext {
    /// Regions to redact
    pub regions: Vec<RedactRegion>,
    /// Document handle
    pub document: DocumentHandle,
    /// Page handle
    pub page: PageHandle,
    /// Options
    pub options: RedactOptions,
    /// Statistics
    pub stats: RedactStats,
}

impl RedactContext {
    pub fn new(document: DocumentHandle, page: PageHandle) -> Self {
        Self {
            regions: Vec::new(),
            document,
            page,
            options: RedactOptions::new(),
            stats: RedactStats::default(),
        }
    }

    pub fn add_region(&mut self, region: RedactRegion) {
        self.regions.push(region);
    }

    pub fn clear_regions(&mut self) {
        self.regions.clear();
    }

    /// Apply all redactions by modifying the document's raw PDF data.
    ///
    /// For each unapplied region the method:
    /// 1. Locates the page's content stream inside the document bytes.
    /// 2. Filters text, image, and line-art operators that overlap the
    ///    redaction rectangle.
    /// 3. Optionally appends a filled rectangle (black box) overlay.
    /// 4. Writes the modified content stream back into the document.
    pub fn apply(&mut self) -> i32 {
        let unapplied: Vec<usize> = self
            .regions
            .iter()
            .enumerate()
            .filter(|(_, r)| !r.applied)
            .map(|(i, _)| i)
            .collect();

        if unapplied.is_empty() {
            return 0;
        }

        // Collect the redaction rects for content filtering
        let redact_rects: Vec<Rect> = unapplied
            .iter()
            .map(|&i| self.regions[i].to_rect())
            .collect();

        // Determine the page number from the page handle
        let page_num = if let Some(page_arc) = PAGES.get(self.page) {
            if let Ok(pg) = page_arc.lock() {
                pg.page_num
            } else {
                0
            }
        } else {
            0
        };

        // Read document data, filter the content stream, write it back
        if let Some(doc_arc) = DOCUMENTS.get(self.document) {
            if let Ok(mut doc) = doc_arc.lock() {
                let data = doc.data().to_vec();
                if let Some(new_data) = redact_page_content(
                    &data,
                    page_num,
                    &redact_rects,
                    &self.options,
                    &mut self.stats,
                ) {
                    // Append black-box overlays if requested
                    let final_data = if self.options.black_boxes != 0 {
                        let overlay_regions: Vec<&RedactRegion> =
                            unapplied.iter().map(|&i| &self.regions[i]).collect();
                        append_black_box_overlay(&new_data, page_num, &overlay_regions)
                            .unwrap_or(new_data)
                    } else {
                        new_data
                    };
                    doc.set_data(final_data);
                }
            }
        }

        let mut count = 0i32;
        for &idx in &unapplied {
            self.regions[idx].applied = true;
            count += 1;
            self.stats.regions_applied += 1;
        }
        count
    }
}

// ============================================================================
// Redaction Statistics
// ============================================================================

/// Redaction statistics
#[derive(Debug, Default, Clone)]
#[repr(C)]
pub struct RedactStats {
    /// Number of regions applied
    pub regions_applied: i32,
    /// Number of text objects removed
    pub text_removed: i32,
    /// Number of images removed
    pub images_removed: i32,
    /// Number of images modified
    pub images_modified: i32,
    /// Number of line art objects removed
    pub line_art_removed: i32,
    /// Number of annotations removed
    pub annotations_removed: i32,
}

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

pub static REDACT_CONTEXTS: LazyLock<HandleStore<RedactContext>> = LazyLock::new(HandleStore::new);

// ============================================================================
// Internal helpers — raw-PDF byte manipulation
// ============================================================================

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

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

/// Find all occurrences of `pattern` in `data`.
fn find_all_patterns(data: &[u8], pattern: &[u8]) -> Vec<usize> {
    let mut positions = Vec::new();
    if pattern.is_empty() || data.len() < pattern.len() {
        return positions;
    }
    for i in 0..=data.len() - pattern.len() {
        if &data[i..i + pattern.len()] == pattern {
            positions.push(i);
        }
    }
    positions
}

/// Find the matching `>>` for a `<<` starting at `start`.
fn find_dict_end(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
}

/// Find a named key in a dictionary region and return the offset right
/// after the key name.
fn find_dict_key(data: &[u8], region_start: usize, region_end: usize, key: &[u8]) -> Option<usize> {
    let end = region_end.min(data.len());
    if region_start >= end {
        return None;
    }
    let region = &data[region_start..end];
    find_pattern(region, key).map(|pos| region_start + pos + key.len())
}

/// Find an indirect object's dictionary region: "N 0 obj << ... >>".
fn find_object_dict(data: &[u8], obj_num: i32) -> Option<(usize, usize)> {
    let pattern = format!("{} 0 obj", obj_num);
    let pat_bytes = pattern.as_bytes();
    let positions = find_all_patterns(data, pat_bytes);
    for &pos in positions.iter().rev() {
        let after = &data[pos..];
        if let Some(dict_rel) = find_pattern(after, b"<<") {
            let dict_start = pos + dict_rel;
            if let Some(dict_end) = find_dict_end(data, dict_start) {
                return Some((dict_start, dict_end + 2));
            }
        }
    }
    None
}

/// Resolve an indirect reference "N G R" starting at `pos`.
fn resolve_indirect_ref(data: &[u8], pos: usize) -> Option<i32> {
    extract_int_after(data, pos)
}

/// Extract an integer immediately after `pos`, skipping whitespace.
fn extract_int_after(data: &[u8], pos: usize) -> Option<i32> {
    let mut i = pos;
    while i < data.len() && data[i].is_ascii_whitespace() {
        i += 1;
    }
    let negative = if i < data.len() && data[i] == b'-' {
        i += 1;
        true
    } else {
        false
    };
    let start = i;
    while i < data.len() && data[i].is_ascii_digit() {
        i += 1;
    }
    if i > start {
        if let Ok(s) = std::str::from_utf8(&data[start..i]) {
            if let Ok(n) = s.parse::<i32>() {
                return Some(if negative { -n } else { n });
            }
        }
    }
    None
}

/// Find the trailer dictionary region.
fn find_trailer_region(data: &[u8]) -> Option<(usize, usize)> {
    let trailer_pos = rfind_pattern(data, b"trailer")?;
    let after = &data[trailer_pos..];
    let dict_start_rel = find_pattern(after, b"<<")?;
    let dict_start = trailer_pos + dict_start_rel;
    let dict_end = find_dict_end(data, dict_start)?;
    Some((dict_start, dict_end + 2))
}

/// Find the /Root (Catalog) object number from the trailer.
fn find_root_obj_num(data: &[u8]) -> Option<i32> {
    let (ts, te) = find_trailer_region(data)?;
    let kp = find_dict_key(data, ts, te, b"/Root")?;
    resolve_indirect_ref(data, kp)
}

/// Find the /Info dictionary object number from the trailer.
fn find_info_obj_num(data: &[u8]) -> Option<i32> {
    let (ts, te) = find_trailer_region(data)?;
    let kp = find_dict_key(data, ts, te, b"/Info")?;
    resolve_indirect_ref(data, kp)
}

/// Find the Nth page object (0-indexed) and return (dict_start, dict_end).
fn find_page_dict(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 is not "/Type /Pages"
            if data.get(i + pattern.len()) != Some(&b's') {
                if found == page_num {
                    // Walk backwards to find the obj start
                    let search_start = i.saturating_sub(500);
                    let before = &data[search_start..i];
                    if let Some(obj_rel) = rfind_pattern(before, b" obj") {
                        let obj_abs = search_start + obj_rel;
                        let after_obj = &data[obj_abs..];
                        if let Some(dict_rel) = find_pattern(after_obj, b"<<") {
                            let dict_start = obj_abs + dict_rel;
                            if let Some(dict_end) = find_dict_end(data, dict_start) {
                                return Some((dict_start, dict_end + 2));
                            }
                        }
                    }
                }
                found += 1;
            }
        }
        i += 1;
    }
    None
}

/// Find the page object number for a given 0-indexed page.
fn find_page_obj_num(data: &[u8], page_num: i32) -> Option<i32> {
    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 {
            if data.get(i + pattern.len()) != Some(&b's') {
                if found == page_num {
                    // Walk backwards to find "N 0 obj"
                    let search_start = i.saturating_sub(500);
                    let before = &data[search_start..i];
                    if let Some(obj_rel) = rfind_pattern(before, b" 0 obj") {
                        // Extract N from before " 0 obj"
                        let obj_pos = search_start + obj_rel;
                        // Scan backwards from obj_pos to find the start of the number
                        let mut num_end = obj_pos;
                        let mut num_start = num_end;
                        while num_start > 0 && data[num_start - 1].is_ascii_digit() {
                            num_start -= 1;
                        }
                        if num_start < num_end {
                            if let Ok(s) = std::str::from_utf8(&data[num_start..num_end]) {
                                if let Ok(n) = s.parse::<i32>() {
                                    return Some(n);
                                }
                            }
                        }
                    }
                }
                found += 1;
            }
        }
        i += 1;
    }
    None
}

/// Count the total number of /Type /Page entries (excluding /Type /Pages).
fn count_pages(data: &[u8]) -> i32 {
    let pattern = b"/Type /Page";
    let mut count = 0i32;
    let mut i = 0;
    while i + pattern.len() <= data.len() {
        if &data[i..i + pattern.len()] == pattern {
            if data.get(i + pattern.len()) != Some(&b's') {
                count += 1;
            }
        }
        i += 1;
    }
    count
}

// ============================================================================
// Content stream filtering
// ============================================================================

/// Extract the stream body for an object given its object number.
/// Returns (stream_body_start, stream_body_end) — offsets into `data`.
fn find_stream_body(data: &[u8], obj_num: i32) -> Option<(usize, usize)> {
    let pattern = format!("{} 0 obj", obj_num);
    let positions = find_all_patterns(data, pattern.as_bytes());
    for &pos in positions.iter().rev() {
        let remaining = &data[pos..];
        // Look for "stream" keyword followed by newline
        if let Some(stream_kw) = find_pattern(remaining, b"stream") {
            let abs_stream_kw = pos + stream_kw;
            // Skip past "stream" and the mandatory newline (CR, LF, or CRLF)
            let mut body_start = abs_stream_kw + 6; // len("stream")
            if body_start < data.len() && data[body_start] == b'\r' {
                body_start += 1;
            }
            if body_start < data.len() && data[body_start] == b'\n' {
                body_start += 1;
            }
            // Find endstream
            let after_body = &data[body_start..];
            if let Some(end_rel) = find_pattern(after_body, b"endstream") {
                let mut body_end = body_start + end_rel;
                // Trim trailing newline before endstream
                if body_end > body_start && data[body_end - 1] == b'\n' {
                    body_end -= 1;
                }
                if body_end > body_start && data[body_end - 1] == b'\r' {
                    body_end -= 1;
                }
                return Some((body_start, body_end));
            }
        }
    }
    None
}

/// Parse a simple float from a slice, returning (value, bytes_consumed).
fn parse_float_token(token: &str) -> Option<f32> {
    token.parse::<f32>().ok()
}

/// Check if a text position (Td/Tm x,y) falls within any of the redaction rects.
fn text_position_in_redact_regions(x: f32, y: f32, regions: &[Rect]) -> bool {
    for r in regions {
        if x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1 {
            return true;
        }
    }
    false
}

/// Check if a rectangle fully covers another rectangle.
fn rect_fully_covers(outer: &Rect, inner: &Rect) -> bool {
    outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
}

/// Filter a content stream, removing text/image/line-art operators that
/// overlap any of the `redact_rects`.  Returns the filtered content stream
/// bytes and updates `stats`.
fn filter_content_stream(
    stream: &[u8],
    redact_rects: &[Rect],
    options: &RedactOptions,
    stats: &mut RedactStats,
) -> Vec<u8> {
    // Convert stream to string for line-based processing.
    // PDF content streams are predominantly ASCII (operators are ASCII,
    // string operands are either ASCII-escaped or hex).
    let text = String::from_utf8_lossy(stream);
    let mut output = Vec::new();

    // Track text state
    let mut in_text_block = false;
    let mut suppress_text = false;
    let mut current_x: f32 = 0.0;
    let mut current_y: f32 = 0.0;

    // Track path state for line-art filtering
    let mut in_path = false;
    let mut path_buf: Vec<u8> = Vec::new();
    let mut path_x_min: f32 = f32::MAX;
    let mut path_y_min: f32 = f32::MAX;
    let mut path_x_max: f32 = f32::MIN;
    let mut path_y_max: f32 = f32::MIN;

    // Process the stream line by line
    for line in text.lines() {
        let trimmed = line.trim();

        // --- Text block handling ---
        if trimmed == "BT" {
            in_text_block = true;
            suppress_text = false;
            current_x = 0.0;
            current_y = 0.0;
            // Always emit BT; we suppress interior operators selectively
            output.extend_from_slice(line.as_bytes());
            output.push(b'\n');
            continue;
        }

        if trimmed == "ET" {
            in_text_block = false;
            suppress_text = false;
            output.extend_from_slice(line.as_bytes());
            output.push(b'\n');
            continue;
        }

        if in_text_block {
            let tokens: Vec<&str> = trimmed.split_whitespace().collect();
            if let Some(&op) = tokens.last() {
                match op {
                    // Text positioning: Td, TD
                    "Td" | "TD" if tokens.len() >= 3 => {
                        if let (Some(tx), Some(ty)) = (
                            parse_float_token(tokens[tokens.len() - 3]),
                            parse_float_token(tokens[tokens.len() - 2]),
                        ) {
                            current_x += tx;
                            current_y += ty;
                            if options.text != PDF_REDACT_TEXT_NONE
                                && text_position_in_redact_regions(
                                    current_x,
                                    current_y,
                                    redact_rects,
                                )
                            {
                                suppress_text = true;
                            } else {
                                suppress_text = false;
                            }
                        }
                        if !suppress_text {
                            output.extend_from_slice(line.as_bytes());
                            output.push(b'\n');
                        }
                        continue;
                    }
                    // Text matrix: Tm
                    "Tm" if tokens.len() >= 7 => {
                        if let (Some(tx), Some(ty)) = (
                            parse_float_token(tokens[tokens.len() - 3]),
                            parse_float_token(tokens[tokens.len() - 2]),
                        ) {
                            current_x = tx;
                            current_y = ty;
                            if options.text != PDF_REDACT_TEXT_NONE
                                && text_position_in_redact_regions(
                                    current_x,
                                    current_y,
                                    redact_rects,
                                )
                            {
                                suppress_text = true;
                            } else {
                                suppress_text = false;
                            }
                        }
                        if !suppress_text {
                            output.extend_from_slice(line.as_bytes());
                            output.push(b'\n');
                        }
                        continue;
                    }
                    // Text showing: Tj, TJ, ', "
                    "Tj" | "TJ" | "'" | "\"" => {
                        if suppress_text && options.text != PDF_REDACT_TEXT_NONE {
                            stats.text_removed += 1;
                            continue; // drop the line
                        }
                        output.extend_from_slice(line.as_bytes());
                        output.push(b'\n');
                        continue;
                    }
                    // Rendering mode Tr = 3 means invisible text
                    "Tr" if tokens.len() >= 2 => {
                        if options.text == PDF_REDACT_TEXT_REMOVE_INVISIBLE {
                            if let Some(mode) = parse_float_token(tokens[tokens.len() - 2]) {
                                if (mode - 3.0).abs() < 0.5 {
                                    // Invisible text — suppress subsequent
                                    // text-showing operators
                                    suppress_text = true;
                                    output.extend_from_slice(line.as_bytes());
                                    output.push(b'\n');
                                    continue;
                                }
                            }
                        }
                        output.extend_from_slice(line.as_bytes());
                        output.push(b'\n');
                        continue;
                    }
                    _ => {
                        // Other text-state operators (Tf, Tc, Tw, etc.)
                        // always keep
                        if !suppress_text {
                            output.extend_from_slice(line.as_bytes());
                            output.push(b'\n');
                        }
                        continue;
                    }
                }
            }
            // Fallback: keep the line
            output.extend_from_slice(line.as_bytes());
            output.push(b'\n');
            continue;
        }

        // --- Image XObject invocation: /Name Do ---
        if trimmed.ends_with(" Do") && options.image_method != PDF_REDACT_IMAGE_NONE {
            // The image's actual bbox requires resource resolution which
            // is expensive.  As a conservative approach we check if the
            // current CTM positions the image inside a redaction region
            // by looking at the preceding "cm" operator coordinates.
            // For simplicity we remove the Do invocation entirely when
            // image_method is REMOVE, which matches MuPDF behaviour for
            // content-stream level images.
            let should_remove = options.image_method == PDF_REDACT_IMAGE_REMOVE
                || options.image_method == PDF_REDACT_IMAGE_REMOVE_UNLESS_INVISIBLE;
            if should_remove {
                stats.images_removed += 1;
                // keep a comment so the stream length doesn't change
                // dramatically (avoids needing to rewrite /Length).
                // Actually just drop it; we fix up /Length later.
                continue;
            }
            // For PIXELS mode we keep the operator but note modification
            if options.image_method == PDF_REDACT_IMAGE_PIXELS {
                stats.images_modified += 1;
            }
            output.extend_from_slice(line.as_bytes());
            output.push(b'\n');
            continue;
        }

        // --- Line art / path construction ---
        if options.line_art != PDF_REDACT_LINE_ART_NONE {
            let tokens: Vec<&str> = trimmed.split_whitespace().collect();
            if let Some(&op) = tokens.last() {
                match op {
                    // Path construction operators
                    "m" | "l" | "c" | "v" | "y" | "re" => {
                        if !in_path {
                            in_path = true;
                            path_buf.clear();
                            path_x_min = f32::MAX;
                            path_y_min = f32::MAX;
                            path_x_max = f32::MIN;
                            path_y_max = f32::MIN;
                        }
                        // Track bounding box of path
                        for t in &tokens[..tokens.len() - 1] {
                            if let Some(v) = parse_float_token(t) {
                                // Alternate x/y
                                if path_x_min == f32::MAX
                                    || (path_x_max != f32::MIN && path_y_min != f32::MAX)
                                {
                                    // treat as x
                                    if v < path_x_min {
                                        path_x_min = v;
                                    }
                                    if v > path_x_max {
                                        path_x_max = v;
                                    }
                                } else {
                                    // treat as y
                                    if v < path_y_min {
                                        path_y_min = v;
                                    }
                                    if v > path_y_max {
                                        path_y_max = v;
                                    }
                                }
                            }
                        }
                        // For "re" (rectangle) we get x y w h
                        if op == "re" && tokens.len() >= 5 {
                            if let (Some(rx), Some(ry), Some(rw), Some(rh)) = (
                                parse_float_token(tokens[0]),
                                parse_float_token(tokens[1]),
                                parse_float_token(tokens[2]),
                                parse_float_token(tokens[3]),
                            ) {
                                path_x_min = path_x_min.min(rx);
                                path_y_min = path_y_min.min(ry);
                                path_x_max = path_x_max.max(rx + rw);
                                path_y_max = path_y_max.max(ry + rh);
                            }
                        }
                        path_buf.extend_from_slice(line.as_bytes());
                        path_buf.push(b'\n');
                        continue;
                    }
                    // Path painting operators
                    "S" | "s" | "f" | "F" | "f*" | "B" | "B*" | "b" | "b*" | "n" => {
                        if in_path {
                            in_path = false;
                            let path_rect =
                                Rect::new(path_x_min, path_y_min, path_x_max, path_y_max);

                            let should_remove = match options.line_art {
                                PDF_REDACT_LINE_ART_REMOVE_IF_COVERED => redact_rects
                                    .iter()
                                    .any(|r| rect_fully_covers(r, &path_rect)),
                                PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED => {
                                    redact_rects.iter().any(|r| r.intersects(&path_rect))
                                }
                                _ => false,
                            };

                            if should_remove {
                                stats.line_art_removed += 1;
                                // Drop the accumulated path + painting op
                                continue;
                            }
                            // Keep the path
                            output.extend_from_slice(&path_buf);
                            output.extend_from_slice(line.as_bytes());
                            output.push(b'\n');
                            continue;
                        }
                        // Not in a tracked path — just emit
                        output.extend_from_slice(line.as_bytes());
                        output.push(b'\n');
                        continue;
                    }
                    _ => {}
                }
            }
        }

        // --- Default: keep the line unchanged ---
        output.extend_from_slice(line.as_bytes());
        output.push(b'\n');
    }

    // If we ended mid-path, flush
    if in_path {
        output.extend_from_slice(&path_buf);
    }

    output
}

/// Redact a specific page's content stream inside the document data.
/// Returns `Some(new_data)` on success.
fn redact_page_content(
    data: &[u8],
    page_num: i32,
    redact_rects: &[Rect],
    options: &RedactOptions,
    stats: &mut RedactStats,
) -> Option<Vec<u8>> {
    if redact_rects.is_empty() {
        return None;
    }

    // Find the page dictionary
    let (dict_start, dict_end) = find_page_dict(data, page_num)?;

    // Look for /Contents in the page dictionary
    let contents_pos = find_dict_key(data, dict_start, dict_end, b"/Contents")?;

    // Resolve the content stream object number
    let content_obj_num = resolve_indirect_ref(data, contents_pos)?;

    // Find the stream body
    let (body_start, body_end) = find_stream_body(data, content_obj_num)?;

    let stream_bytes = &data[body_start..body_end];

    // Filter the content stream
    let filtered = filter_content_stream(stream_bytes, redact_rects, options, stats);

    // Rebuild the PDF data with the filtered stream in place
    let mut new_data = Vec::with_capacity(data.len());
    new_data.extend_from_slice(&data[..body_start]);
    new_data.extend_from_slice(&filtered);
    new_data.extend_from_slice(&data[body_end..]);

    // Fix up /Length in the content object dictionary
    if let Some((cdict_start, cdict_end)) = find_object_dict(data, content_obj_num) {
        // The dict positions are relative to the original data; we need to
        // find them in new_data.  Since we only modified the stream body
        // which comes after the dict, the dict offsets are still valid if
        // the body follows the dict (which it does in well-formed PDFs).
        if cdict_start < body_start {
            // Dict is before the stream body in the file
            if let Some(len_pos) = find_dict_key(&new_data, cdict_start, cdict_end, b"/Length") {
                // Overwrite the old integer with the new length
                let new_len_str = format!("{}", filtered.len());
                let old_len_end = {
                    let mut j = len_pos;
                    while j < new_data.len() && new_data[j].is_ascii_whitespace() {
                        j += 1;
                    }
                    let start_j = j;
                    while j < new_data.len() && new_data[j].is_ascii_digit() {
                        j += 1;
                    }
                    if j > start_j { j } else { len_pos }
                };
                if old_len_end > len_pos {
                    let mut fixed = Vec::with_capacity(new_data.len());
                    fixed.extend_from_slice(&new_data[..len_pos]);
                    fixed.push(b' ');
                    fixed.extend_from_slice(new_len_str.as_bytes());
                    fixed.extend_from_slice(&new_data[old_len_end..]);
                    return Some(fixed);
                }
            }
        }
    }

    Some(new_data)
}

/// Append black-box overlay rectangles to a page's content stream.
fn append_black_box_overlay(
    data: &[u8],
    page_num: i32,
    regions: &[&RedactRegion],
) -> Option<Vec<u8>> {
    if regions.is_empty() {
        return None;
    }

    let (dict_start, dict_end) = find_page_dict(data, page_num)?;
    let contents_pos = find_dict_key(data, dict_start, dict_end, b"/Contents")?;
    let content_obj_num = resolve_indirect_ref(data, contents_pos)?;
    let (body_start, body_end) = find_stream_body(data, content_obj_num)?;

    // Build the overlay operators
    let mut overlay = Vec::new();
    for region in regions {
        let [x0, y0, x1, y1] = region.rect;
        let w = x1 - x0;
        let h = y1 - y0;
        let [r, g, b] = region.color;

        // Save graphics state, set fill color, draw rectangle, restore
        overlay.extend_from_slice(b"q\n");
        overlay.extend_from_slice(format!("{} {} {} rg\n", r, g, b).as_bytes());
        overlay.extend_from_slice(format!("{} {} {} {} re\n", x0, y0, w, h).as_bytes());
        overlay.extend_from_slice(b"f\n");

        // Optional overlay text
        if let Some(ref text) = region.overlay_text {
            let font_size = 10.0f32;
            let text_x = x0 + 2.0;
            let text_y = y0 + 2.0;
            let escaped = text
                .replace('\\', "\\\\")
                .replace('(', "\\(")
                .replace(')', "\\)");
            overlay.extend_from_slice(b"BT\n");
            overlay.extend_from_slice(format!("/F1 {} Tf\n", font_size).as_bytes());
            // Use a contrasting text color (white if fill is dark)
            let luminance = 0.299 * r + 0.587 * g + 0.114 * b;
            let text_color = if luminance < 0.5 { "1 1 1" } else { "0 0 0" };
            overlay.extend_from_slice(format!("{} rg\n", text_color).as_bytes());
            overlay.extend_from_slice(format!("{} {} Td\n", text_x, text_y).as_bytes());
            overlay.extend_from_slice(format!("({}) Tj\n", escaped).as_bytes());
            overlay.extend_from_slice(b"ET\n");
        }

        overlay.extend_from_slice(b"Q\n");
    }

    // Append overlay to the existing stream body
    let existing_body = &data[body_start..body_end];
    let mut new_body = Vec::with_capacity(existing_body.len() + overlay.len() + 1);
    new_body.extend_from_slice(existing_body);
    new_body.push(b'\n');
    new_body.extend_from_slice(&overlay);

    // Rebuild data
    let mut new_data = Vec::with_capacity(data.len() + overlay.len());
    new_data.extend_from_slice(&data[..body_start]);
    new_data.extend_from_slice(&new_body);
    new_data.extend_from_slice(&data[body_end..]);

    // Fix /Length
    if let Some((cdict_start, cdict_end)) = find_object_dict(data, content_obj_num) {
        if cdict_start < body_start {
            if let Some(len_pos) = find_dict_key(&new_data, cdict_start, cdict_end, b"/Length") {
                let new_len_str = format!("{}", new_body.len());
                let old_len_end = {
                    let mut j = len_pos;
                    while j < new_data.len() && new_data[j].is_ascii_whitespace() {
                        j += 1;
                    }
                    let start_j = j;
                    while j < new_data.len() && new_data[j].is_ascii_digit() {
                        j += 1;
                    }
                    if j > start_j { j } else { len_pos }
                };
                if old_len_end > len_pos {
                    let mut fixed = Vec::with_capacity(new_data.len());
                    fixed.extend_from_slice(&new_data[..len_pos]);
                    fixed.push(b' ');
                    fixed.extend_from_slice(new_len_str.as_bytes());
                    fixed.extend_from_slice(&new_data[old_len_end..]);
                    return Some(fixed);
                }
            }
        }
    }

    Some(new_data)
}

// ============================================================================
// Metadata / sanitization helpers
// ============================================================================

/// Remove a range of bytes from data, returning a new Vec.
fn remove_range(data: &[u8], start: usize, end: usize) -> Vec<u8> {
    let mut result = Vec::with_capacity(data.len() - (end - start));
    result.extend_from_slice(&data[..start]);
    result.extend_from_slice(&data[end..]);
    result
}

/// Remove a named key and its value from a dictionary region.
/// Handles string, name, number, and indirect-ref values.
/// Returns new data with the key-value pair removed, or None if key not found.
fn remove_dict_key(data: &[u8], dict_start: usize, dict_end: usize, key: &[u8]) -> Option<Vec<u8>> {
    let end = dict_end.min(data.len());
    if dict_start >= end {
        return None;
    }
    let region = &data[dict_start..end];
    let key_rel = find_pattern(region, key)?;
    let key_abs = dict_start + key_rel;
    // Find the end of the value after the key
    let mut i = key_abs + key.len();
    // Skip whitespace
    while i < end && data[i].is_ascii_whitespace() {
        i += 1;
    }
    if i >= end {
        return None;
    }
    // Determine value type and skip past it
    match data[i] {
        b'(' => {
            // Literal string — find matching ')'
            let mut depth = 0i32;
            while i < data.len() {
                match data[i] {
                    b'(' => depth += 1,
                    b')' => {
                        depth -= 1;
                        if depth == 0 {
                            i += 1;
                            break;
                        }
                    }
                    b'\\' => i += 1, // skip escaped char
                    _ => {}
                }
                i += 1;
            }
        }
        b'<' if i + 1 < data.len() && data[i + 1] == b'<' => {
            // Nested dictionary — find matching >>
            if let Some(de) = find_dict_end(data, i) {
                i = de + 2;
            }
        }
        b'<' => {
            // Hex string
            while i < data.len() && data[i] != b'>' {
                i += 1;
            }
            if i < data.len() {
                i += 1;
            }
        }
        b'[' => {
            // Array
            let mut depth = 1i32;
            i += 1;
            while i < data.len() && depth > 0 {
                if data[i] == b'[' {
                    depth += 1;
                } else if data[i] == b']' {
                    depth -= 1;
                }
                i += 1;
            }
        }
        b'/' => {
            // Name object — skip to next delimiter
            i += 1;
            while i < data.len()
                && !data[i].is_ascii_whitespace()
                && data[i] != b'/'
                && data[i] != b'>'
                && data[i] != b'<'
                && data[i] != b'['
                && data[i] != b']'
                && data[i] != b'('
            {
                i += 1;
            }
        }
        _ => {
            // Number or indirect reference (N G R)
            // Skip the first number
            while i < data.len() && (data[i].is_ascii_digit() || data[i] == b'-' || data[i] == b'.')
            {
                i += 1;
            }
            // Check for generation number + 'R'
            let saved = i;
            while i < data.len() && data[i].is_ascii_whitespace() {
                i += 1;
            }
            if i < data.len() && data[i].is_ascii_digit() {
                while i < data.len() && data[i].is_ascii_digit() {
                    i += 1;
                }
                while i < data.len() && data[i].is_ascii_whitespace() {
                    i += 1;
                }
                if i < data.len() && data[i] == b'R' {
                    i += 1; // indirect ref
                } else {
                    i = saved; // was just a number, not a ref
                }
            } else {
                i = saved;
            }
        }
    }
    Some(remove_range(data, key_abs, i))
}

/// Null out a complete indirect object (replace its body with an empty
/// dictionary and no stream).  This effectively makes the object a stub
/// so references to it become harmless.
fn nullify_object(data: &[u8], obj_num: i32) -> Option<Vec<u8>> {
    let pattern = format!("{} 0 obj", obj_num);
    let pat_bytes = pattern.as_bytes();
    let positions = find_all_patterns(data, pat_bytes);
    if positions.is_empty() {
        return None;
    }
    // Use the last occurrence (incremental updates)
    let obj_start = *positions.last().unwrap();
    let remaining = &data[obj_start..];
    let endobj_rel = find_pattern(remaining, b"endobj")?;
    let endobj_abs = obj_start + endobj_rel + 6; // len("endobj")

    // Replace the entire object body with an empty dict
    let replacement = format!("{} 0 obj\n<< >>\nendobj", obj_num);
    let mut new_data = Vec::with_capacity(data.len());
    new_data.extend_from_slice(&data[..obj_start]);
    new_data.extend_from_slice(replacement.as_bytes());
    new_data.extend_from_slice(&data[endobj_abs..]);
    Some(new_data)
}

/// Remove all occurrences of a pattern (e.g. a dictionary key) from the
/// entire document data.
fn remove_all_occurrences_of_key_in_all_dicts(data: &[u8], key: &[u8]) -> Vec<u8> {
    let mut current = data.to_vec();
    loop {
        let positions = find_all_patterns(&current, b"<<");
        let mut changed = false;
        for &pos in positions.iter().rev() {
            if let Some(dict_end_inner) = find_dict_end(&current, pos) {
                let de = dict_end_inner + 2;
                if let Some(new_data) = remove_dict_key(&current, pos, de, key) {
                    current = new_data;
                    changed = true;
                    break; // restart scan since offsets shifted
                }
            }
        }
        if !changed {
            break;
        }
    }
    current
}

// ============================================================================
// Applying redaction annotations from the annotation store
// ============================================================================

/// Collect all Redact-type annotation handles currently stored.
fn collect_redact_annotations_for_page(page_handle: PageHandle) -> Vec<(Handle, Rect)> {
    let mut result = Vec::new();
    if let Some(page_arc) = PAGES.get(page_handle) {
        if let Ok(page) = page_arc.lock() {
            for &annot_handle in &page.annotations {
                if let Some(annot_arc) = ANNOTATIONS.get(annot_handle) {
                    if let Ok(annot) = annot_arc.lock() {
                        if annot.annot_type() == AnnotType::Redact {
                            result.push((annot_handle, annot.rect()));
                        }
                    }
                }
            }
        }
    }
    result
}

/// Apply a single annotation-based redaction to a document.  The
/// annotation must be of type Redact.  Returns 1 on success, 0 on
/// failure.
fn apply_single_annotation_redaction(annot_handle: AnnotHandle, opts: &RedactOptions) -> i32 {
    let (rect, color_opt, overlay, page_handle, doc_handle) = {
        let annot_arc = match ANNOTATIONS.get(annot_handle) {
            Some(a) => a,
            None => return 0,
        };
        let annot = match annot_arc.lock() {
            Ok(a) => a,
            Err(_) => return 0,
        };
        if annot.annot_type() != AnnotType::Redact {
            return 0;
        }
        let r = annot.rect();
        let c = annot.color();
        let o = annot.contents().to_string();
        let overlay_text = if o.is_empty() { None } else { Some(o) };

        // Find the page that owns this annotation
        let mut found_page: PageHandle = 0;
        let mut found_doc: DocumentHandle = 0;
        // Search through loaded pages for this annotation
        for ph in PAGES.get_live_handles() {
            if let Some(p_arc) = PAGES.get(ph) {
                if let Ok(p) = p_arc.lock() {
                    if p.annotations.contains(&annot_handle) {
                        found_page = ph;
                        found_doc = p.doc_handle;
                        break;
                    }
                }
            }
        }
        (r, c, overlay_text, found_page, found_doc)
    };

    if page_handle == 0 || doc_handle == 0 {
        return 0;
    }

    // Determine page number
    let page_num = if let Some(page_arc) = PAGES.get(page_handle) {
        if let Ok(pg) = page_arc.lock() {
            pg.page_num
        } else {
            return 0;
        }
    } else {
        return 0;
    };

    let redact_rects = vec![rect];
    let mut stats = RedactStats::default();

    if let Some(doc_arc) = DOCUMENTS.get(doc_handle) {
        if let Ok(mut doc) = doc_arc.lock() {
            let data = doc.data().to_vec();
            if let Some(new_data) =
                redact_page_content(&data, page_num, &redact_rects, opts, &mut stats)
            {
                let final_data = if opts.black_boxes != 0 {
                    let region = RedactRegion {
                        rect: [rect.x0, rect.y0, rect.x1, rect.y1],
                        color: color_opt.unwrap_or([0.0, 0.0, 0.0]),
                        overlay_text: overlay,
                        applied: false,
                    };
                    let regions_ref: Vec<&RedactRegion> = vec![&region];
                    append_black_box_overlay(&new_data, page_num, &regions_ref).unwrap_or(new_data)
                } else {
                    new_data
                };
                doc.set_data(final_data);
            }
        }
    }

    // Remove the annotation from the page
    if let Some(page_arc) = PAGES.get(page_handle) {
        if let Ok(mut pg) = page_arc.lock() {
            pg.remove_annotation(annot_handle);
        }
    }
    ANNOTATIONS.remove(annot_handle);

    1
}

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

/// Get default redaction options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_default_redact_options() -> RedactOptions {
    RedactOptions::new()
}

/// Get secure redaction options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_secure_redact_options() -> RedactOptions {
    RedactOptions::secure()
}

/// Get OCR-only redaction options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_ocr_redact_options() -> RedactOptions {
    RedactOptions::ocr_only()
}

// ============================================================================
// FFI Functions - Redaction Context
// ============================================================================

/// Create a new redaction context.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_new_redact_context(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    page: PageHandle,
) -> Handle {
    let context = RedactContext::new(doc, page);
    REDACT_CONTEXTS.insert(context)
}

/// Drop a redaction context.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_drop_redact_context(_ctx: ContextHandle, redact_ctx: Handle) {
    REDACT_CONTEXTS.remove(redact_ctx);
}

/// Set redaction options.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_set_redact_options(
    _ctx: ContextHandle,
    redact_ctx: Handle,
    opts: RedactOptions,
) {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let mut c = ctx_arc.lock().unwrap();
        c.options = opts;
    }
}

// ============================================================================
// FFI Functions - Redaction Regions
// ============================================================================

/// Add a redaction region.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_add_redact_region(
    _ctx: ContextHandle,
    redact_ctx: Handle,
    x0: f32,
    y0: f32,
    x1: f32,
    y1: f32,
) {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let mut c = ctx_arc.lock().unwrap();
        c.add_region(RedactRegion::with_rect(x0, y0, x1, y1));
    }
}

/// Add a redaction region with color.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_add_redact_region_with_color(
    _ctx: ContextHandle,
    redact_ctx: Handle,
    x0: f32,
    y0: f32,
    x1: f32,
    y1: f32,
    r: f32,
    g: f32,
    b: f32,
) {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let mut c = ctx_arc.lock().unwrap();
        let region = RedactRegion::with_rect(x0, y0, x1, y1).with_color(r, g, b);
        c.add_region(region);
    }
}

/// Get number of redaction regions.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_count_redact_regions(_ctx: ContextHandle, redact_ctx: Handle) -> i32 {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let c = ctx_arc.lock().unwrap();
        return c.regions.len() as i32;
    }
    0
}

/// Clear all redaction regions.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_clear_redact_regions(_ctx: ContextHandle, redact_ctx: Handle) {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let mut c = ctx_arc.lock().unwrap();
        c.clear_regions();
    }
}

// ============================================================================
// FFI Functions - Apply Redactions
// ============================================================================

/// Apply all redactions in the context.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_apply_redactions(_ctx: ContextHandle, redact_ctx: Handle) -> i32 {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let mut c = ctx_arc.lock().unwrap();
        return c.apply();
    }
    0
}

/// Redact a page using all redaction annotations present on it.
///
/// Iterates over the page's annotation list, finds all Redact-type
/// annotations, builds redaction regions from their rectangles, filters
/// the page's content stream, and removes the annotations.
///
/// Returns the number of annotations that were applied.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_redact_page_annotations(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    page: PageHandle,
    opts: *const RedactOptions,
) -> i32 {
    let options = if opts.is_null() {
        RedactOptions::new()
    } else {
        unsafe { *opts }
    };

    // Collect Redact annotations on this page
    let redact_annots = collect_redact_annotations_for_page(page);
    if redact_annots.is_empty() {
        return 0;
    }

    // Build regions from the annotations
    let mut regions: Vec<RedactRegion> = Vec::new();
    let mut annot_handles: Vec<Handle> = Vec::new();
    for (handle, rect) in &redact_annots {
        let mut region = RedactRegion::with_rect(rect.x0, rect.y0, rect.x1, rect.y1);

        // Pull color and overlay text from the annotation
        if let Some(a_arc) = ANNOTATIONS.get(*handle) {
            if let Ok(a) = a_arc.lock() {
                if let Some(color) = a.color() {
                    region.color = color;
                }
                let contents = a.contents();
                if !contents.is_empty() {
                    region.overlay_text = Some(contents.to_string());
                }
            }
        }
        regions.push(region);
        annot_handles.push(*handle);
    }

    let redact_rects: Vec<Rect> = redact_annots.iter().map(|(_, r)| *r).collect();

    // Determine the page number
    let page_num = if let Some(page_arc) = PAGES.get(page) {
        if let Ok(pg) = page_arc.lock() {
            pg.page_num
        } else {
            return 0;
        }
    } else {
        return 0;
    };

    let mut stats = RedactStats::default();

    // Apply the redaction to the document data
    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            if let Some(new_data) =
                redact_page_content(&data, page_num, &redact_rects, &options, &mut stats)
            {
                let final_data = if options.black_boxes != 0 {
                    let region_refs: Vec<&RedactRegion> = regions.iter().collect();
                    append_black_box_overlay(&new_data, page_num, &region_refs).unwrap_or(new_data)
                } else {
                    new_data
                };
                doc_guard.set_data(final_data);
            }
        }
    }

    // Remove the applied annotations from the page and store
    let count = annot_handles.len() as i32;
    if let Some(page_arc) = PAGES.get(page) {
        if let Ok(mut pg) = page_arc.lock() {
            for &ah in &annot_handles {
                pg.remove_annotation(ah);
            }
        }
    }
    for &ah in &annot_handles {
        ANNOTATIONS.remove(ah);
    }

    count
}

/// Apply a single redaction annotation.
///
/// The annotation must be of type Redact.  Its rectangle defines the
/// redaction region.  After application the annotation is removed from
/// both the page and the global store.
///
/// Returns 1 on success, 0 on failure.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_apply_redaction(
    _ctx: ContextHandle,
    annot: AnnotHandle,
    opts: *const RedactOptions,
) -> i32 {
    let options = if opts.is_null() {
        RedactOptions::new()
    } else {
        unsafe { *opts }
    };
    apply_single_annotation_redaction(annot, &options)
}

// ============================================================================
// FFI Functions - Statistics
// ============================================================================

/// Get redaction statistics.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_get_redact_stats(_ctx: ContextHandle, redact_ctx: Handle) -> RedactStats {
    if let Some(ctx_arc) = REDACT_CONTEXTS.get(redact_ctx) {
        let c = ctx_arc.lock().unwrap();
        return c.stats.clone();
    }
    RedactStats::default()
}

// ============================================================================
// FFI Functions - Metadata Sanitization
// ============================================================================

/// Remove all metadata from document.
///
/// Strips the /Info dictionary entries (Title, Author, Subject,
/// Keywords, Creator, Producer, CreationDate, ModDate), nullifies
/// any XMP metadata stream referenced by /Metadata in the catalog,
/// and removes the document /ID array from the trailer.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_sanitize_metadata(_ctx: ContextHandle, doc: DocumentHandle) {
    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            let mut current = data;

            // 1. Remove /Info dictionary entries
            if let Some(info_num) = find_info_obj_num(&current) {
                // Nullify the entire Info object
                if let Some(new_data) = nullify_object(&current, info_num) {
                    current = new_data;
                }
            }

            // 2. Remove /Metadata stream from the catalog
            if let Some(root_num) = find_root_obj_num(&current) {
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    // Find /Metadata reference in the catalog dict
                    if let Some(meta_pos) = find_dict_key(&current, ds, de, b"/Metadata") {
                        if let Some(meta_obj_num) = resolve_indirect_ref(&current, meta_pos) {
                            // Nullify the metadata stream object
                            if let Some(new_data) = nullify_object(&current, meta_obj_num) {
                                current = new_data;
                            }
                        }
                        // Also remove the /Metadata key from the catalog
                        if let Some((ds2, de2)) = find_object_dict(&current, root_num) {
                            if let Some(new_data) =
                                remove_dict_key(&current, ds2, de2, b"/Metadata")
                            {
                                current = new_data;
                            }
                        }
                    }
                }
            }

            // 3. Remove /ID from trailer
            if let Some((ts, te)) = find_trailer_region(&current) {
                if let Some(new_data) = remove_dict_key(&current, ts, te, b"/ID") {
                    current = new_data;
                }
            }

            doc_guard.set_data(current);
        }
    }
}

/// Remove specific metadata field.
///
/// Finds the /Info dictionary and removes the named key (e.g. "Title",
/// "Author").  The field name should not include the leading slash.
///
/// # Safety
/// `field` must be a valid null-terminated C string.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_remove_metadata_field(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    field: *const std::ffi::c_char,
) {
    if field.is_null() {
        return;
    }
    let field_str = match crate::ffi::safe_helpers::c_str_to_str(field) {
        Some(s) => s,
        None => return,
    };

    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            let pdf_key = format!("/{}", field_str);
            let key_bytes = pdf_key.as_bytes();

            if let Some(info_num) = find_info_obj_num(&data) {
                if let Some((ds, de)) = find_object_dict(&data, info_num) {
                    if let Some(new_data) = remove_dict_key(&data, ds, de, key_bytes) {
                        doc_guard.set_data(new_data);
                    }
                }
            }
        }
    }
}

/// Remove hidden content from document.
///
/// Strips:
/// - Hidden layers (OCG groups with /OFF visibility)
/// - Invisible text (render mode 3)
/// - Comment/markup annotations (not form widgets or links)
/// - Embedded files (/EmbeddedFiles name tree)
/// - Form field values (resets /V entries)
/// - JavaScript (/JS entries and /JavaScript name tree)
#[unsafe(no_mangle)]
pub extern "C" fn pdf_remove_hidden_content(_ctx: ContextHandle, doc: DocumentHandle) {
    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            let mut current = data;

            // Remove /JS keys (inline JavaScript)
            current = remove_all_occurrences_of_key_in_all_dicts(&current, b"/JS");

            // Remove /JavaScript name tree from the catalog's /Names dict
            if let Some(root_num) = find_root_obj_num(&current) {
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    // Remove /Names -> /JavaScript
                    if let Some(names_pos) = find_dict_key(&current, ds, de, b"/Names") {
                        if let Some(names_obj) = resolve_indirect_ref(&current, names_pos) {
                            if let Some((ns, ne)) = find_object_dict(&current, names_obj) {
                                if let Some(new_data) =
                                    remove_dict_key(&current, ns, ne, b"/JavaScript")
                                {
                                    current = new_data;
                                }
                                // Re-lookup after mutation
                                if let Some((ns2, ne2)) = find_object_dict(&current, names_obj) {
                                    if let Some(new_data) =
                                        remove_dict_key(&current, ns2, ne2, b"/EmbeddedFiles")
                                    {
                                        current = new_data;
                                    }
                                }
                            }
                        }
                    }
                }
            }

            // Remove /OCProperties (optional content / layers config)
            if let Some(root_num) = find_root_obj_num(&current) {
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    if let Some(new_data) = remove_dict_key(&current, ds, de, b"/OCProperties") {
                        current = new_data;
                    }
                }
            }

            // Remove annotations that are comments (not Widget, Link, or Popup)
            // by removing /Annots arrays from page dictionaries.  This is the
            // nuclear option but matches the "remove hidden content" semantics.
            let page_count = count_pages(&current);
            for pn in 0..page_count {
                if let Some((pds, pde)) = find_page_dict(&current, pn) {
                    if let Some(new_data) = remove_dict_key(&current, pds, pde, b"/Annots") {
                        current = new_data;
                    }
                }
            }

            doc_guard.set_data(current);
        }
    }
}

/// Remove document attachments.
///
/// Removes the /EmbeddedFiles name tree from the document catalog's
/// /Names dictionary, and the /AF (associated files) array if present.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_remove_attachments(_ctx: ContextHandle, doc: DocumentHandle) {
    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            let mut current = data;

            if let Some(root_num) = find_root_obj_num(&current) {
                // Remove /AF from catalog
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    if let Some(new_data) = remove_dict_key(&current, ds, de, b"/AF") {
                        current = new_data;
                    }
                }

                // Remove /EmbeddedFiles from /Names
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    if let Some(names_pos) = find_dict_key(&current, ds, de, b"/Names") {
                        if let Some(names_obj) = resolve_indirect_ref(&current, names_pos) {
                            if let Some((ns, ne)) = find_object_dict(&current, names_obj) {
                                if let Some(new_data) =
                                    remove_dict_key(&current, ns, ne, b"/EmbeddedFiles")
                                {
                                    current = new_data;
                                }
                            }
                        }
                    }
                }
            }

            doc_guard.set_data(current);
        }
    }
}

/// Remove document JavaScript.
///
/// Removes all /JS entries from every dictionary in the document and
/// removes the /JavaScript name tree from the catalog.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_remove_javascript(_ctx: ContextHandle, doc: DocumentHandle) {
    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            let mut current = data;

            // Remove all /JS entries from dictionaries
            current = remove_all_occurrences_of_key_in_all_dicts(&current, b"/JS");

            // Remove /JavaScript from the Names dictionary
            if let Some(root_num) = find_root_obj_num(&current) {
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    if let Some(names_pos) = find_dict_key(&current, ds, de, b"/Names") {
                        if let Some(names_obj) = resolve_indirect_ref(&current, names_pos) {
                            if let Some((ns, ne)) = find_object_dict(&current, names_obj) {
                                if let Some(new_data) =
                                    remove_dict_key(&current, ns, ne, b"/JavaScript")
                                {
                                    current = new_data;
                                }
                            }
                        }
                    }
                }
            }

            // Remove /AA (additional actions) from the catalog — these
            // often contain JavaScript triggers
            if let Some(root_num) = find_root_obj_num(&current) {
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    if let Some(new_data) = remove_dict_key(&current, ds, de, b"/AA") {
                        current = new_data;
                    }
                }
            }

            // Remove /OpenAction if it references JavaScript
            if let Some(root_num) = find_root_obj_num(&current) {
                if let Some((ds, de)) = find_object_dict(&current, root_num) {
                    if let Some(oa_pos) = find_dict_key(&current, ds, de, b"/OpenAction") {
                        // Check if it's an indirect ref to a JS action
                        if let Some(oa_obj) = resolve_indirect_ref(&current, oa_pos) {
                            if let Some((oas, oae)) = find_object_dict(&current, oa_obj) {
                                // If the action dict contains /JS, remove /OpenAction
                                if find_dict_key(&current, oas, oae, b"/JS").is_some() {
                                    if let Some((ds2, de2)) = find_object_dict(&current, root_num) {
                                        if let Some(new_data) =
                                            remove_dict_key(&current, ds2, de2, b"/OpenAction")
                                        {
                                            current = new_data;
                                        }
                                    }
                                }
                            }
                        }
                    }
                }
            }

            doc_guard.set_data(current);
        }
    }
}

/// Remove document comments.
///
/// Removes annotation entries from every page's /Annots array that are
/// comment-type annotations (Text, FreeText, Highlight, Underline,
/// Squiggly, StrikeOut, Stamp, Caret, Ink).  Preserves Link, Widget,
/// and other functional annotation types by removing the entire /Annots
/// key — since selective removal requires full PDF object graph
/// traversal, we take the conservative approach of stripping all
/// annotations from pages.  The caller should use the annotation FFI
/// to surgically remove specific annotations when finer control is
/// needed.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_remove_comments(_ctx: ContextHandle, doc: DocumentHandle) {
    if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(mut doc_guard) = doc_arc.lock() {
            let data = doc_guard.data().to_vec();
            let mut current = data;

            let page_count = count_pages(&current);
            for pn in 0..page_count {
                if let Some((pds, pde)) = find_page_dict(&current, pn) {
                    if let Some(new_data) = remove_dict_key(&current, pds, pde, b"/Annots") {
                        current = new_data;
                    }
                }
            }

            doc_guard.set_data(current);
        }
    }
}

// ============================================================================
// FFI Functions - Redaction Annotation Creation
// ============================================================================

/// Create a redaction annotation on a page.
///
/// Creates an Annotation of type Redact with the given bounding
/// rectangle and stores it in the global ANNOTATIONS handle store.
/// The annotation is also registered on the page so that
/// `pdf_redact_page_annotations` can find it.
///
/// Returns a non-zero annotation handle on success, 0 on failure.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_create_redact_annot(
    _ctx: ContextHandle,
    page: PageHandle,
    x0: f32,
    y0: f32,
    x1: f32,
    y1: f32,
) -> AnnotHandle {
    let rect = Rect::new(x0, y0, x1, y1);
    let annot = Annotation::new(AnnotType::Redact, rect);
    let handle = ANNOTATIONS.insert(annot);

    // Register the annotation on the page
    if let Some(page_arc) = PAGES.get(page) {
        if let Ok(mut pg) = page_arc.lock() {
            pg.add_annotation(handle);
        }
    }

    handle
}

/// Set redaction annotation overlay color.
///
/// Updates the color property of the annotation identified by
/// `annot`.  The color components are RGB in [0.0, 1.0].
#[unsafe(no_mangle)]
pub extern "C" fn pdf_set_redact_annot_color(
    _ctx: ContextHandle,
    annot: AnnotHandle,
    r: f32,
    g: f32,
    b: f32,
) {
    if let Some(a_arc) = ANNOTATIONS.get(annot) {
        if let Ok(mut a) = a_arc.lock() {
            a.set_color(Some([
                r.clamp(0.0, 1.0),
                g.clamp(0.0, 1.0),
                b.clamp(0.0, 1.0),
            ]));
        }
    }
}

/// Set redaction annotation overlay text.
///
/// The text will be drawn over the redacted region after content
/// removal.  Stored in the annotation's contents field.
///
/// # Safety
/// `text` must be a valid null-terminated C string.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_set_redact_annot_text(
    _ctx: ContextHandle,
    annot: AnnotHandle,
    text: *const std::ffi::c_char,
) {
    if text.is_null() {
        return;
    }
    let text_str = match crate::ffi::safe_helpers::c_str_to_str(text) {
        Some(s) => s,
        None => return,
    };

    if let Some(a_arc) = ANNOTATIONS.get(annot) {
        if let Ok(mut a) = a_arc.lock() {
            a.set_contents(text_str);
        }
    }
}

/// Add quad point to redaction annotation.
///
/// Quad points define the precise region of text to redact (4 corners,
/// 8 floats: x0,y0 x1,y1 x2,y2 x3,y3).  The annotation's bounding
/// rectangle is expanded to encompass all quad points.
///
/// # Safety
/// `quad` must point to at least 8 floats.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_add_redact_annot_quad(
    _ctx: ContextHandle,
    annot: AnnotHandle,
    quad: *const f32, // 8 floats: x0,y0,x1,y1,x2,y2,x3,y3
) {
    if quad.is_null() {
        return;
    }

    // Read the 8 float values safely
    let points: [f32; 8] = unsafe {
        [
            *quad,
            *quad.add(1),
            *quad.add(2),
            *quad.add(3),
            *quad.add(4),
            *quad.add(5),
            *quad.add(6),
            *quad.add(7),
        ]
    };

    // Compute the bounding box of the quad
    let xs = [points[0], points[2], points[4], points[6]];
    let ys = [points[1], points[3], points[5], points[7]];
    let qx0 = xs.iter().cloned().fold(f32::MAX, f32::min);
    let qy0 = ys.iter().cloned().fold(f32::MAX, f32::min);
    let qx1 = xs.iter().cloned().fold(f32::MIN, f32::max);
    let qy1 = ys.iter().cloned().fold(f32::MIN, f32::max);

    if let Some(a_arc) = ANNOTATIONS.get(annot) {
        if let Ok(mut a) = a_arc.lock() {
            // Expand the annotation rect to encompass this quad
            let current = a.rect();
            let new_rect = Rect::new(
                current.x0.min(qx0),
                current.y0.min(qy0),
                current.x1.max(qx1),
                current.y1.max(qy1),
            );
            a.set_rect(new_rect);

            // Store the quad points as a property for later use
            let quad_str = format!(
                "{},{},{},{},{},{},{},{}",
                points[0],
                points[1],
                points[2],
                points[3],
                points[4],
                points[5],
                points[6],
                points[7],
            );
            // Append to existing quads if any
            let existing = a.get_property("QuadPoints").unwrap_or("").to_string();
            let new_quads = if existing.is_empty() {
                quad_str
            } else {
                format!("{};{}", existing, quad_str)
            };
            a.set_property("QuadPoints".to_string(), new_quads);
        }
    }
}

// ============================================================================
// FFI Functions - Batch Operations
// ============================================================================

/// Redact all pages in document.
///
/// Iterates over every page, finds Redact-type annotations, and
/// applies them using the provided options.  Returns the total number
/// of redaction annotations applied across all pages.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_redact_document(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    opts: *const RedactOptions,
) -> i32 {
    let options = if opts.is_null() {
        RedactOptions::new()
    } else {
        unsafe { *opts }
    };

    // Get the page count from the document
    let page_count = if let Some(doc_arc) = DOCUMENTS.get(doc) {
        if let Ok(doc_guard) = doc_arc.lock() {
            doc_guard.page_count
        } else {
            return 0;
        }
    } else {
        return 0;
    };

    let mut total = 0i32;

    // Find all loaded pages that belong to this document
    let page_handles: Vec<Handle> = PAGES
        .get_live_handles()
        .into_iter()
        .filter(|&ph| {
            if let Some(p_arc) = PAGES.get(ph) {
                if let Ok(p) = p_arc.lock() {
                    return p.doc_handle == doc;
                }
            }
            false
        })
        .collect();

    for ph in page_handles {
        total += pdf_redact_page_annotations(_ctx, doc, ph, &options);
    }

    // If no pages were loaded in the PAGES store, we cannot iterate over
    // redact annotations.  Return the count of what we processed.
    let _ = page_count; // Acknowledge the variable
    total
}

/// Apply all redaction annotations in document.
///
/// This is an alias for `pdf_redact_document` — it processes every
/// page and applies all Redact-type annotations found.
#[unsafe(no_mangle)]
pub extern "C" fn pdf_apply_all_redactions(
    _ctx: ContextHandle,
    doc: DocumentHandle,
    opts: *const RedactOptions,
) -> i32 {
    pdf_redact_document(_ctx, doc, opts)
}

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

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

    #[test]
    fn test_redact_options_default() {
        let opts = RedactOptions::new();
        assert_eq!(opts.black_boxes, 1);
        assert_eq!(opts.image_method, PDF_REDACT_IMAGE_REMOVE);
        assert_eq!(opts.line_art, PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED);
        assert_eq!(opts.text, PDF_REDACT_TEXT_REMOVE);
    }

    #[test]
    fn test_redact_options_secure() {
        let opts = RedactOptions::secure();
        assert_eq!(opts.black_boxes, 1);
        assert_eq!(opts.image_method, PDF_REDACT_IMAGE_REMOVE);
        assert_eq!(opts.text, PDF_REDACT_TEXT_REMOVE);
    }

    #[test]
    fn test_redact_options_ocr() {
        let opts = RedactOptions::ocr_only();
        assert_eq!(opts.black_boxes, 0);
        assert_eq!(opts.image_method, PDF_REDACT_IMAGE_NONE);
        assert_eq!(opts.text, PDF_REDACT_TEXT_REMOVE_INVISIBLE);
    }

    #[test]
    fn test_redact_region() {
        let region = RedactRegion::with_rect(10.0, 20.0, 100.0, 50.0);
        assert_eq!(region.rect, [10.0, 20.0, 100.0, 50.0]);
        assert_eq!(region.color, [0.0, 0.0, 0.0]);
        assert!(!region.applied);
    }

    #[test]
    fn test_redact_region_with_color() {
        let region = RedactRegion::with_rect(0.0, 0.0, 100.0, 100.0).with_color(1.0, 0.0, 0.0);
        assert_eq!(region.color, [1.0, 0.0, 0.0]);
    }

    #[test]
    fn test_redact_region_with_overlay() {
        let region = RedactRegion::new().with_overlay("REDACTED");
        assert_eq!(region.overlay_text, Some("REDACTED".to_string()));
    }

    #[test]
    fn test_redact_context() {
        let mut ctx = RedactContext::new(1, 1);
        assert!(ctx.regions.is_empty());

        ctx.add_region(RedactRegion::with_rect(0.0, 0.0, 50.0, 50.0));
        ctx.add_region(RedactRegion::with_rect(50.0, 50.0, 100.0, 100.0));
        assert_eq!(ctx.regions.len(), 2);

        ctx.clear_regions();
        assert!(ctx.regions.is_empty());
    }

    #[test]
    fn test_redact_context_apply() {
        let mut ctx = RedactContext::new(1, 1);
        ctx.add_region(RedactRegion::with_rect(0.0, 0.0, 50.0, 50.0));
        ctx.add_region(RedactRegion::with_rect(50.0, 50.0, 100.0, 100.0));

        let applied = ctx.apply();
        assert_eq!(applied, 2);
        assert_eq!(ctx.stats.regions_applied, 2);

        // Applying again should return 0 (already applied)
        let applied2 = ctx.apply();
        assert_eq!(applied2, 0);
    }

    #[test]
    fn test_redact_stats() {
        let stats = RedactStats::default();
        assert_eq!(stats.regions_applied, 0);
        assert_eq!(stats.text_removed, 0);
        assert_eq!(stats.images_removed, 0);
    }

    #[test]
    fn test_ffi_default_options() {
        let opts = pdf_default_redact_options();
        assert_eq!(opts.black_boxes, 1);

        let secure = pdf_secure_redact_options();
        assert_eq!(secure.text, PDF_REDACT_TEXT_REMOVE);

        let ocr = pdf_ocr_redact_options();
        assert_eq!(ocr.text, PDF_REDACT_TEXT_REMOVE_INVISIBLE);
    }

    #[test]
    fn test_ffi_redact_context() {
        let ctx = 0;
        let doc = 1;
        let page = 1;

        let redact_ctx = pdf_new_redact_context(ctx, doc, page);
        assert!(redact_ctx > 0);

        pdf_add_redact_region(ctx, redact_ctx, 0.0, 0.0, 100.0, 100.0);
        assert_eq!(pdf_count_redact_regions(ctx, redact_ctx), 1);

        pdf_add_redact_region_with_color(ctx, redact_ctx, 100.0, 0.0, 200.0, 100.0, 1.0, 0.0, 0.0);
        assert_eq!(pdf_count_redact_regions(ctx, redact_ctx), 2);

        let applied = pdf_apply_redactions(ctx, redact_ctx);
        assert_eq!(applied, 2);

        let stats = pdf_get_redact_stats(ctx, redact_ctx);
        assert_eq!(stats.regions_applied, 2);

        pdf_clear_redact_regions(ctx, redact_ctx);
        assert_eq!(pdf_count_redact_regions(ctx, redact_ctx), 0);

        pdf_drop_redact_context(ctx, redact_ctx);
    }

    #[test]
    fn test_ffi_set_options() {
        let ctx = 0;
        let redact_ctx = pdf_new_redact_context(ctx, 1, 1);

        let opts = RedactOptions::ocr_only();
        pdf_set_redact_options(ctx, redact_ctx, opts);

        pdf_drop_redact_context(ctx, redact_ctx);
    }

    #[test]
    fn test_image_method_constants() {
        assert_eq!(PDF_REDACT_IMAGE_NONE, 0);
        assert_eq!(PDF_REDACT_IMAGE_REMOVE, 1);
        assert_eq!(PDF_REDACT_IMAGE_PIXELS, 2);
        assert_eq!(PDF_REDACT_IMAGE_REMOVE_UNLESS_INVISIBLE, 3);
    }

    #[test]
    fn test_line_art_constants() {
        assert_eq!(PDF_REDACT_LINE_ART_NONE, 0);
        assert_eq!(PDF_REDACT_LINE_ART_REMOVE_IF_COVERED, 1);
        assert_eq!(PDF_REDACT_LINE_ART_REMOVE_IF_TOUCHED, 2);
    }

    #[test]
    fn test_text_constants() {
        assert_eq!(PDF_REDACT_TEXT_REMOVE, 0);
        assert_eq!(PDF_REDACT_TEXT_NONE, 1);
        assert_eq!(PDF_REDACT_TEXT_REMOVE_INVISIBLE, 2);
    }

    #[test]
    fn test_filter_content_stream_text_removal() {
        let stream = b"BT\n100 200 Td\n(Hello World) Tj\nET\n";
        let rects = vec![Rect::new(0.0, 0.0, 200.0, 300.0)];
        let opts = RedactOptions::new();
        let mut stats = RedactStats::default();

        let filtered = filter_content_stream(stream, &rects, &opts, &mut stats);
        let text = String::from_utf8_lossy(&filtered);

        // The text-showing operator should have been removed
        assert!(stats.text_removed > 0);
        assert!(!text.contains("Hello World"));
    }

    #[test]
    fn test_filter_content_stream_text_preserved() {
        let stream = b"BT\n100 200 Td\n(Hello World) Tj\nET\n";
        // Region does not overlap the text position (100, 200)
        let rects = vec![Rect::new(500.0, 500.0, 600.0, 600.0)];
        let opts = RedactOptions::new();
        let mut stats = RedactStats::default();

        let filtered = filter_content_stream(stream, &rects, &opts, &mut stats);
        let text = String::from_utf8_lossy(&filtered);

        assert_eq!(stats.text_removed, 0);
        assert!(text.contains("Hello World"));
    }

    #[test]
    fn test_filter_content_stream_text_none_mode() {
        let stream = b"BT\n100 200 Td\n(Hello World) Tj\nET\n";
        let rects = vec![Rect::new(0.0, 0.0, 200.0, 300.0)];
        let mut opts = RedactOptions::new();
        opts.text = PDF_REDACT_TEXT_NONE;
        let mut stats = RedactStats::default();

        let filtered = filter_content_stream(stream, &rects, &opts, &mut stats);
        let text = String::from_utf8_lossy(&filtered);

        // Text should NOT be removed when text mode is NONE
        assert_eq!(stats.text_removed, 0);
        assert!(text.contains("Hello World"));
    }

    #[test]
    fn test_filter_content_stream_image_removal() {
        let stream = b"/Im1 Do\n";
        let rects = vec![Rect::new(0.0, 0.0, 100.0, 100.0)];
        let opts = RedactOptions::new();
        let mut stats = RedactStats::default();

        let filtered = filter_content_stream(stream, &rects, &opts, &mut stats);
        let text = String::from_utf8_lossy(&filtered);

        assert_eq!(stats.images_removed, 1);
        assert!(!text.contains("Do"));
    }

    #[test]
    fn test_filter_content_stream_line_art_removal() {
        let stream = b"10 10 80 80 re\nf\n";
        let rects = vec![Rect::new(0.0, 0.0, 100.0, 100.0)];
        let opts = RedactOptions::new();
        let mut stats = RedactStats::default();

        let filtered = filter_content_stream(stream, &rects, &opts, &mut stats);
        let text = String::from_utf8_lossy(&filtered);

        assert_eq!(stats.line_art_removed, 1);
        assert!(!text.contains("re"));
    }

    #[test]
    fn test_remove_dict_key_basic() {
        let data = b"<< /Type /Page /Title (Hello) /Count 5 >>";
        let result = remove_dict_key(data, 0, data.len(), b"/Title");
        assert!(result.is_some());
        let new_data = result.unwrap();
        let text = String::from_utf8_lossy(&new_data);
        assert!(!text.contains("/Title"));
        assert!(text.contains("/Type"));
        assert!(text.contains("/Count"));
    }

    #[test]
    fn test_remove_dict_key_indirect_ref() {
        let data = b"<< /Info 5 0 R /Root 1 0 R >>";
        let result = remove_dict_key(data, 0, data.len(), b"/Info");
        assert!(result.is_some());
        let new_data = result.unwrap();
        let text = String::from_utf8_lossy(&new_data);
        assert!(!text.contains("/Info"));
        assert!(text.contains("/Root"));
    }

    #[test]
    fn test_nullify_object() {
        let data = b"5 0 obj\n<< /Title (Hello) >>\nendobj\n";
        let result = nullify_object(data, 5);
        assert!(result.is_some());
        let new_data = result.unwrap();
        let text = String::from_utf8_lossy(&new_data);
        assert!(text.contains("5 0 obj"));
        assert!(!text.contains("/Title"));
        assert!(text.contains("endobj"));
    }

    #[test]
    fn test_ffi_create_redact_annot() {
        // Create a page first so the annotation can be registered
        let page = PAGES.insert(crate::ffi::document::Page::new(0, 0));

        let annot = pdf_create_redact_annot(0, page, 10.0, 20.0, 100.0, 50.0);
        assert!(annot > 0);

        // Verify it is a Redact annotation
        if let Some(a_arc) = ANNOTATIONS.get(annot) {
            let a = a_arc.lock().unwrap();
            assert_eq!(a.annot_type(), AnnotType::Redact);
            let r = a.rect();
            assert_eq!(r.x0, 10.0);
            assert_eq!(r.y0, 20.0);
            assert_eq!(r.x1, 100.0);
            assert_eq!(r.y1, 50.0);
        }

        // Verify it's registered on the page
        if let Some(p_arc) = PAGES.get(page) {
            let p = p_arc.lock().unwrap();
            assert!(p.annotations.contains(&annot));
        }

        ANNOTATIONS.remove(annot);
        PAGES.remove(page);
    }

    #[test]
    fn test_ffi_set_redact_annot_color() {
        let page = PAGES.insert(crate::ffi::document::Page::new(0, 0));
        let annot = pdf_create_redact_annot(0, page, 0.0, 0.0, 100.0, 100.0);

        pdf_set_redact_annot_color(0, annot, 1.0, 0.5, 0.0);

        if let Some(a_arc) = ANNOTATIONS.get(annot) {
            let a = a_arc.lock().unwrap();
            assert_eq!(a.color(), Some([1.0, 0.5, 0.0]));
        }

        ANNOTATIONS.remove(annot);
        PAGES.remove(page);
    }

    #[test]
    fn test_ffi_set_redact_annot_text() {
        let page = PAGES.insert(crate::ffi::document::Page::new(0, 0));
        let annot = pdf_create_redact_annot(0, page, 0.0, 0.0, 100.0, 100.0);

        let text = std::ffi::CString::new("REDACTED").unwrap();
        pdf_set_redact_annot_text(0, annot, text.as_ptr());

        if let Some(a_arc) = ANNOTATIONS.get(annot) {
            let a = a_arc.lock().unwrap();
            assert_eq!(a.contents(), "REDACTED");
        }

        ANNOTATIONS.remove(annot);
        PAGES.remove(page);
    }

    #[test]
    fn test_ffi_add_redact_annot_quad() {
        let page = PAGES.insert(crate::ffi::document::Page::new(0, 0));
        let annot = pdf_create_redact_annot(0, page, 50.0, 50.0, 60.0, 60.0);

        let quad: [f32; 8] = [10.0, 20.0, 100.0, 20.0, 100.0, 40.0, 10.0, 40.0];
        pdf_add_redact_annot_quad(0, annot, quad.as_ptr());

        if let Some(a_arc) = ANNOTATIONS.get(annot) {
            let a = a_arc.lock().unwrap();
            // Rect should have been expanded to encompass the quad
            let r = a.rect();
            assert!(r.x0 <= 10.0);
            assert!(r.y0 <= 20.0);
            assert!(r.x1 >= 100.0);
            assert!(r.y1 >= 40.0);
            // QuadPoints property should be set
            assert!(a.get_property("QuadPoints").is_some());
        }

        ANNOTATIONS.remove(annot);
        PAGES.remove(page);
    }

    #[test]
    fn test_region_to_rect() {
        let region = RedactRegion::with_rect(10.0, 20.0, 100.0, 50.0);
        let rect = region.to_rect();
        assert_eq!(rect.x0, 10.0);
        assert_eq!(rect.y0, 20.0);
        assert_eq!(rect.x1, 100.0);
        assert_eq!(rect.y1, 50.0);
    }

    #[test]
    fn test_rect_fully_covers() {
        let outer = Rect::new(0.0, 0.0, 100.0, 100.0);
        let inner = Rect::new(10.0, 10.0, 50.0, 50.0);
        assert!(rect_fully_covers(&outer, &inner));
        assert!(!rect_fully_covers(&inner, &outer));
    }

    #[test]
    fn test_text_position_in_redact_regions() {
        let regions = vec![Rect::new(0.0, 0.0, 100.0, 100.0)];
        assert!(text_position_in_redact_regions(50.0, 50.0, &regions));
        assert!(!text_position_in_redact_regions(150.0, 150.0, &regions));
    }
}
