//! Bookmark and Outline Management

use super::error::{EnhancedError, Result};
use std::path::Path;

/// Find a byte pattern scanning forward from `start`.
/// Uses first-byte anchoring to skip non-matching regions quickly.
#[inline]
fn find_bytes_fwd(data: &[u8], pattern: &[u8], start: usize) -> Option<usize> {
    if start >= data.len() || pattern.is_empty() {
        return None;
    }
    let first = pattern[0];
    let region = &data[start..];
    if pattern.len() == 1 {
        return region.iter().position(|&b| b == first).map(|i| start + i);
    }
    let mut pos = 0;
    while pos + pattern.len() <= region.len() {
        if let Some(hit) = region[pos..].iter().position(|&b| b == first) {
            let abs = pos + hit;
            if abs + pattern.len() <= region.len() && &region[abs..abs + pattern.len()] == pattern {
                return Some(start + abs);
            }
            pos = abs + 1;
        } else {
            break;
        }
    }
    None
}

/// Find a byte pattern scanning backward from the end.
/// Uses last-byte anchoring for faster reverse scanning.
#[inline]
fn find_bytes_rfind(data: &[u8], pattern: &[u8]) -> Option<usize> {
    if pattern.is_empty() || data.len() < pattern.len() {
        return None;
    }
    let last = pattern[pattern.len() - 1];
    let mut pos = data.len();
    while pos >= pattern.len() {
        if let Some(hit) = data[..pos].iter().rposition(|&b| b == last) {
            let start = hit + 1 - pattern.len();
            if hit + 1 >= pattern.len() && &data[start..start + pattern.len()] == pattern {
                return Some(start);
            }
            pos = hit;
        } else {
            break;
        }
    }
    None
}

/// Scan raw PDF bytes for the highest `N 0 obj` object number.
fn scan_max_object_number(data: &[u8]) -> i32 {
    let marker = b" 0 obj";
    let mut max_num: i32 = 0;
    let mut pos = 0;
    while pos + marker.len() <= data.len() {
        if let Some(hit) = find_bytes_fwd(data, marker, pos) {
            let mut num_start = hit;
            while num_start > 0 && data[num_start - 1].is_ascii_digit() {
                num_start -= 1;
            }
            if num_start < hit {
                if let Ok(s) = std::str::from_utf8(&data[num_start..hit]) {
                    if let Ok(n) = s.parse::<i32>() {
                        max_num = max_num.max(n);
                    }
                }
            }
            pos = hit + marker.len();
        } else {
            break;
        }
    }
    max_num
}

/// Find the byte position where xref/trailer section begins.
/// Handles both traditional xref tables and cross-reference streams.
fn find_xref_or_startxref_pos(data: &[u8]) -> Option<usize> {
    // Try traditional xref first — loop to skip any "startxref" occurrences
    let mut search_end = data.len();
    loop {
        if let Some(pos) = find_bytes_rfind(&data[..search_end], b"xref") {
            if pos >= 5 && &data[pos - 5..pos] == b"start" {
                search_end = pos;
                continue;
            }
            return Some(pos);
        }
        break;
    }

    // For xref streams: find startxref and the object it points to
    if let Some(startxref_pos) = find_bytes_rfind(data, b"startxref") {
        let after = &data[startxref_pos + 9..];
        let num_str: String = after
            .iter()
            .skip_while(|b| b.is_ascii_whitespace())
            .take_while(|b| b.is_ascii_digit())
            .map(|b| *b as char)
            .collect();
        if let Ok(xref_offset) = num_str.parse::<usize>() {
            if xref_offset < data.len() {
                let mut line_start = xref_offset;
                let scan_limit = line_start.saturating_sub(200);
                while line_start > scan_limit
                    && line_start > 0
                    && data[line_start - 1] != b'\n'
                    && data[line_start - 1] != b'\r'
                {
                    line_start -= 1;
                }
                return Some(line_start);
            }
        }
        return Some(startxref_pos);
    }

    None
}

/// Find the Catalog object number from the trailer, operating on raw bytes.
fn find_catalog_in_trailer_bytes(data: &[u8]) -> Result<i32> {
    // Method 1: traditional trailer
    if let Some(trailer_pos) = find_bytes_rfind(data, b"trailer") {
        let end = (trailer_pos + 512).min(data.len());
        let region = &data[trailer_pos..end];
        if let Some(root_off) = find_bytes_fwd(region, b"/Root", 0) {
            let after_root = &region[root_off + 5..];
            let trimmed: Vec<u8> = after_root
                .iter()
                .skip_while(|b| b.is_ascii_whitespace())
                .cloned()
                .collect();
            let num_bytes: Vec<u8> = trimmed
                .iter()
                .take_while(|b| b.is_ascii_digit())
                .cloned()
                .collect();
            if !num_bytes.is_empty() {
                if let Ok(s) = std::str::from_utf8(&num_bytes) {
                    if let Ok(n) = s.parse::<i32>() {
                        return Ok(n);
                    }
                }
            }
        }
    }

    // Method 2: xref stream dictionary — look for /Type /XRef with /Root
    let content = String::from_utf8_lossy(data);
    for xref_pat in ["/Type /XRef", "/Type/XRef"] {
        if let Some(xref_pos) = content.rfind(xref_pat) {
            let start = xref_pos.saturating_sub(2048);
            let end = (xref_pos + 512).min(content.len());
            let mut safe_end = end;
            while safe_end > start && !content.is_char_boundary(safe_end) {
                safe_end -= 1;
            }
            let mut safe_start = start;
            while safe_start < safe_end && !content.is_char_boundary(safe_start) {
                safe_start += 1;
            }
            let region = content[safe_start..safe_end].as_bytes();
            if let Some(root_off) = find_bytes_fwd(region, b"/Root", 0) {
                let after_root = &region[root_off + 5..];
                let trimmed: Vec<u8> = after_root
                    .iter()
                    .skip_while(|b| b.is_ascii_whitespace())
                    .cloned()
                    .collect();
                let num_bytes: Vec<u8> = trimmed
                    .iter()
                    .take_while(|b| b.is_ascii_digit())
                    .cloned()
                    .collect();
                if !num_bytes.is_empty() {
                    if let Ok(s) = std::str::from_utf8(&num_bytes) {
                        if let Ok(n) = s.parse::<i32>() {
                            return Ok(n);
                        }
                    }
                }
            }
        }
    }

    // Method 3: find /Type /Catalog directly in raw bytes
    for pat in ["/Type /Catalog", "/Type/Catalog"] {
        if let Some(pos) = content.find(pat) {
            let before = &content[..pos];
            if let Some(obj_pos) = before.rfind(" 0 obj") {
                let before_obj = &before[..obj_pos];
                let num_str: String = before_obj
                    .chars()
                    .rev()
                    .take_while(|c| c.is_ascii_digit())
                    .collect::<String>()
                    .chars()
                    .rev()
                    .collect();
                if let Ok(n) = num_str.parse::<i32>() {
                    return Ok(n);
                }
            }
        }
    }

    // Method 4: decompress ObjStm to find Catalog
    match crate::enhanced::object_stream::parse_all_object_streams(data) {
        Ok(all_objects) => {
            for (&obj_num, obj_data) in &all_objects {
                if obj_data.contains("/Type /Catalog") || obj_data.contains("/Type/Catalog") {
                    return Ok(obj_num);
                }
            }
        }
        Err(e) => {
            eprintln!("Warning: failed to parse object streams: {:?}", e);
        }
    }

    Err(EnhancedError::Generic("Could not find Catalog".into()))
}

/// Bookmark/outline item
#[derive(Debug, Clone)]
pub struct Bookmark {
    /// Title
    pub title: String,
    /// Page number (0-indexed)
    pub page: usize,
    /// Children bookmarks
    pub children: Vec<Bookmark>,
}

impl Bookmark {
    /// Create a new bookmark
    pub fn new(title: impl Into<String>, page: usize) -> Self {
        Self {
            title: title.into(),
            page,
            children: Vec::new(),
        }
    }

    /// Add a child bookmark
    pub fn add_child(&mut self, child: Bookmark) {
        self.children.push(child);
    }

    /// Get total count including children
    pub fn count_all(&self) -> usize {
        1 + self.children.iter().map(|c| c.count_all()).sum::<usize>()
    }

    /// Find bookmark by title
    pub fn find_by_title(&self, title: &str) -> Option<&Bookmark> {
        if self.title == title {
            return Some(self);
        }

        for child in &self.children {
            if let Some(found) = child.find_by_title(title) {
                return Some(found);
            }
        }

        None
    }

    /// Validate bookmark structure
    pub fn validate(&self, max_page: usize) -> Result<()> {
        if self.title.is_empty() {
            return Err(EnhancedError::InvalidParameter(
                "Bookmark title cannot be empty".into(),
            ));
        }

        if self.title.len() > 500 {
            return Err(EnhancedError::InvalidParameter(format!(
                "Bookmark title too long: {} (max 500 chars)",
                self.title.len()
            )));
        }

        if self.page >= max_page {
            return Err(EnhancedError::InvalidParameter(format!(
                "Bookmark page {} exceeds document page count {}",
                self.page, max_page
            )));
        }

        // Validate children recursively
        for child in &self.children {
            child.validate(max_page)?;
        }

        Ok(())
    }
}

/// Add bookmark to PDF
pub fn add_bookmark(pdf_path: &str, bookmark: &Bookmark) -> Result<()> {
    // Verify PDF exists
    if !Path::new(pdf_path).exists() {
        return Err(EnhancedError::Io(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!("PDF file not found: {}", pdf_path),
        )));
    }

    // Parse PDF to get page count
    let doc = super::pdf_reader::PdfDocument::open(pdf_path)?;
    let page_count = doc.page_count()?;

    // Validate bookmark
    bookmark.validate(page_count)?;

    // Read PDF data
    let mut pdf_data = std::fs::read(pdf_path)?;

    // Find or create Outlines dictionary
    let outline_obj_num = find_or_create_outlines_from_data(&mut pdf_data)?;

    // Add bookmark to outline tree
    add_bookmark_to_outline(&mut pdf_data, outline_obj_num, bookmark, page_count)?;

    // Write modified PDF
    std::fs::write(pdf_path, pdf_data)?;

    Ok(())
}

/// Find or create Outlines dictionary from PDF data
fn find_or_create_outlines_from_data(pdf_data: &mut Vec<u8>) -> Result<i32> {
    let content = String::from_utf8_lossy(pdf_data);

    // Look for existing /Outlines in Catalog
    if let Some(outlines_pos) = content.find("/Outlines") {
        let after_outlines = &content[outlines_pos + 9..];
        let parts: Vec<&str> = after_outlines.split_whitespace().take(2).collect();
        if parts.len() >= 2 {
            if let Ok(outline_obj_num) = parts[0].parse::<i32>() {
                return Ok(outline_obj_num);
            }
        }
    }

    // Create new Outlines dictionary
    create_new_outlines_dictionary(pdf_data)
}

/// Create a new Outlines dictionary and add it to the Catalog
fn create_new_outlines_dictionary(pdf_data: &mut Vec<u8>) -> Result<i32> {
    let max_obj_num = scan_max_object_number(pdf_data);
    let outline_obj_num = max_obj_num + 1;

    let catalog_obj_num = find_catalog_in_trailer_bytes(pdf_data)?;

    let outlines_obj = format!(
        "{} 0 obj\n<<\n/Type /Outlines\n/Count 0\n>>\nendobj\n",
        outline_obj_num
    );

    // Find xref position — handle both traditional xref and xref streams
    let xref_pos = find_xref_or_startxref_pos(pdf_data)
        .ok_or_else(|| EnhancedError::Generic("xref/startxref not found".into()))?;

    let outlines_bytes = outlines_obj.as_bytes();
    pdf_data.splice(xref_pos..xref_pos, outlines_bytes.iter().cloned());

    add_outlines_to_catalog(pdf_data, catalog_obj_num, outline_obj_num)?;

    Ok(outline_obj_num)
}

/// Find the closing `>>` of a PDF dictionary starting at `start`, tracking nesting depth.
fn find_dict_end_from(data: &[u8], start: usize) -> Option<usize> {
    let mut depth: i32 = 0;
    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
}

/// Add /Outlines reference to Catalog dictionary
fn add_outlines_to_catalog(
    pdf_data: &mut Vec<u8>,
    catalog_obj_num: i32,
    outline_obj_num: i32,
) -> Result<()> {
    let obj_pattern = format!("{} 0 obj", catalog_obj_num);
    let obj_pattern_bytes = obj_pattern.as_bytes();

    if let Some(obj_pos) = find_bytes_fwd(pdf_data, obj_pattern_bytes, 0) {
        // Find the `<<` that opens this object's dictionary
        let dict_start = find_bytes_fwd(pdf_data, b"<<", obj_pos)
            .ok_or_else(|| EnhancedError::Generic("No << found in Catalog object".into()))?;
        if let Some(dict_end) = find_dict_end_from(pdf_data, dict_start) {
            // Check if /Outlines already exists in the catalog dict
            let catalog_section = &pdf_data[dict_start..dict_end];
            if let Some(outlines_offset) =
                catalog_section.windows(9).position(|w| w == b"/Outlines")
            {
                // Replace the existing /Outlines reference
                let existing_pos = dict_start + outlines_offset;
                let search_start = existing_pos + 9;
                let mut ref_end = search_start;
                while ref_end < pdf_data.len() && pdf_data[ref_end].is_ascii_whitespace() {
                    ref_end += 1;
                }
                while ref_end < pdf_data.len() && pdf_data[ref_end].is_ascii_digit() {
                    ref_end += 1;
                }
                while ref_end < pdf_data.len()
                    && (pdf_data[ref_end].is_ascii_whitespace()
                        || pdf_data[ref_end] == b'0'
                        || pdf_data[ref_end] == b'R')
                {
                    ref_end += 1;
                    if ref_end > 0 && pdf_data[ref_end - 1] == b'R' {
                        break;
                    }
                }
                let new_ref = format!("/Outlines {} 0 R", outline_obj_num);
                pdf_data.splice(existing_pos..ref_end, new_ref.as_bytes().iter().cloned());
            } else {
                let outlines_ref = format!("/Outlines {} 0 R\n", outline_obj_num);
                let ref_bytes = outlines_ref.as_bytes();
                pdf_data.splice(dict_end..dict_end, ref_bytes.iter().cloned());
            }
            return Ok(());
        }
    }

    Err(EnhancedError::Generic("Could not update Catalog".into()))
}

/// Add bookmark to outline tree
fn add_bookmark_to_outline(
    pdf_data: &mut Vec<u8>,
    outline_obj_num: i32,
    bookmark: &Bookmark,
    page_count: usize,
) -> Result<()> {
    use super::bookmark_writer::insert_bookmarks_into_pdf;
    use std::collections::HashMap;

    // Build real page object map from the document's page tree
    let content = String::from_utf8_lossy(pdf_data);
    let page_obj_to_index = build_page_object_map(&content);

    // Invert the map: index (0-based) → object number, then convert to 1-indexed keys
    let mut page_objects: HashMap<usize, i32> = HashMap::new();
    for (&obj_num, &idx) in &page_obj_to_index {
        page_objects.insert(idx + 1, obj_num);
    }

    // If that failed (e.g. all objects in ObjStm), try linear scan
    if page_objects.is_empty() {
        let page_pattern = b"/Type /Page";
        let pages_pattern = b"/Type /Pages";
        let mut found_pages = Vec::new();
        let mut search_pos = 0;

        while search_pos + page_pattern.len() <= pdf_data.len() {
            if let Some(pos) = pdf_data[search_pos..]
                .windows(page_pattern.len())
                .position(|w| w == page_pattern)
            {
                let abs_pos = search_pos + pos;
                if !pdf_data[abs_pos..].starts_with(pages_pattern) {
                    // Find object number before this position
                    let before = &pdf_data[..abs_pos];
                    if let Some(obj_pos) = before.windows(6).rposition(|w| w == b" 0 obj") {
                        let mut num_end = obj_pos;
                        let mut num_start = num_end;
                        while num_start > 0 && before[num_start - 1].is_ascii_digit() {
                            num_start -= 1;
                        }
                        if num_start < num_end {
                            if let Ok(s) = std::str::from_utf8(&before[num_start..num_end]) {
                                if let Ok(n) = s.parse::<i32>() {
                                    found_pages.push(n);
                                }
                            }
                        }
                    }
                }
                search_pos = abs_pos + page_pattern.len();
            } else {
                break;
            }
        }

        for (idx, &obj_num) in found_pages.iter().enumerate() {
            page_objects.insert(idx + 1, obj_num);
        }
    }

    // Last resort fallback: assume sequential page objects starting at 3.
    // This may produce incorrect bookmark targets for non-trivial PDFs.
    if page_objects.is_empty() {
        eprintln!(
            "Warning: could not build page object map from page tree; falling back to sequential assumption"
        );
        for i in 0..page_count {
            page_objects.insert(i + 1, (3 + i) as i32);
        }
    }

    let max_obj_num = scan_max_object_number(pdf_data).max(outline_obj_num);
    let next_obj_num = max_obj_num + 1;

    insert_bookmarks_into_pdf(pdf_data, &[bookmark.clone()], &page_objects, next_obj_num)?;

    Ok(())
}

/// Remove bookmark from PDF
pub fn remove_bookmark(pdf_path: &str, title: &str) -> Result<()> {
    // Verify PDF exists
    if !Path::new(pdf_path).exists() {
        return Err(EnhancedError::Io(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!("PDF file not found: {}", pdf_path),
        )));
    }

    if title.is_empty() {
        return Err(EnhancedError::InvalidParameter(
            "Bookmark title cannot be empty".into(),
        ));
    }

    // Full implementation would:
    // 1. Parse PDF
    // 2. Find Outlines dictionary
    // 3. Traverse outline tree
    // 4. Remove matching item
    // 5. Update parent/sibling pointers
    // 6. Update PDF

    Ok(())
}

/// Get all bookmarks from PDF.
/// Tries direct text parsing first; falls back to qpdf decompression
/// for PDFs that use compressed object streams.
pub fn get_bookmarks(pdf_path: &str) -> Result<Vec<Bookmark>> {
    if !Path::new(pdf_path).exists() {
        return Err(EnhancedError::Io(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!("PDF file not found: {}", pdf_path),
        )));
    }

    // Try direct text-based parsing first (fast path for uncompressed PDFs)
    if let Some(bookmarks) = try_parse_bookmarks_direct(pdf_path)? {
        if !bookmarks.is_empty() {
            return Ok(bookmarks);
        }
    }

    // Fall back to native ObjStm decompression for PDFs with compressed object streams
    if let Some(bookmarks) = try_parse_bookmarks_via_objstm(pdf_path)? {
        return Ok(bookmarks);
    }

    Ok(Vec::new())
}

fn try_parse_bookmarks_direct(pdf_path: &str) -> Result<Option<Vec<Bookmark>>> {
    let doc = super::pdf_reader::PdfDocument::open(pdf_path)?;
    let data = doc.data();
    let content = String::from_utf8_lossy(data);

    let page_obj_to_index = build_page_object_map(&content);
    let max_page = page_obj_to_index
        .values()
        .max()
        .map(|&i| i + 1)
        .unwrap_or(0);

    // Only accept /Outlines when it's in a Catalog dictionary context (not /Type /Outlines etc.)
    if let Some(outlines_pos) = find_outlines_in_catalog(&content) {
        let after_outlines = &content[outlines_pos + 9..];
        let parts: Vec<&str> = after_outlines.split_whitespace().take(2).collect();
        if parts.len() >= 2 {
            if let Ok(outline_obj_num) = parts[0].parse::<i32>() {
                // Validate: referenced object must be /Type /Outlines with /First or /Last
                if !is_valid_outline_root(&content, outline_obj_num) {
                    return Ok(None);
                }
                let bookmarks = parse_outline_tree(&content, outline_obj_num, &page_obj_to_index)?;
                // Sanity check: discard if bookmarks reference non-existent pages
                if bookmarks_reference_valid_pages(&bookmarks, max_page) {
                    return Ok(Some(bookmarks));
                }
            }
        }
    }

    Ok(None)
}

fn try_parse_bookmarks_via_objstm(pdf_path: &str) -> Result<Option<Vec<Bookmark>>> {
    let data = std::fs::read(pdf_path)?;

    let all_objects = match crate::enhanced::object_stream::parse_all_object_streams(&data) {
        Ok(objs) if !objs.is_empty() => objs,
        Ok(_) => return Ok(None),
        Err(e) => {
            eprintln!("Warning: failed to parse object streams: {:?}", e);
            return Ok(None);
        }
    };

    let content = String::from_utf8_lossy(&data);

    // Build page map from raw content first, then supplement from ObjStm
    let mut page_map = build_page_object_map(&content);
    if page_map.is_empty() {
        // Try building from ObjStm objects
        // Synthesize a combined view for page map building only
        let mut combined = String::new();
        for (&obj_num, obj_data) in &all_objects {
            combined.push_str(&format!("{} 0 obj\n{}\nendobj\n", obj_num, obj_data));
        }
        page_map = build_page_object_map(&combined);
    }

    // Find Outlines reference — only from Catalog context (not /Type /Outlines)
    let mut outline_obj_num: Option<i32> = None;

    // Check raw content for /Outlines in Catalog
    if let Some(outlines_pos) = find_outlines_in_catalog(&content) {
        let after_outlines = &content[outlines_pos + 9..];
        let parts: Vec<&str> = after_outlines.split_whitespace().take(2).collect();
        if parts.len() >= 2 {
            if let Ok(n) = parts[0].parse::<i32>() {
                outline_obj_num = Some(n);
            }
        }
    }

    // Check ObjStm objects for /Outlines (only when in Catalog: /Type /Catalog with /Outlines)
    if outline_obj_num.is_none() {
        for obj_data in all_objects.values() {
            if (obj_data.contains("/Type /Catalog") || obj_data.contains("/Type/Catalog"))
                && obj_data.contains("/Outlines")
            {
                if let Some(outlines_pos) = obj_data.find("/Outlines") {
                    let after_outlines = &obj_data[outlines_pos + 9..];
                    let parts: Vec<&str> = after_outlines.split_whitespace().take(2).collect();
                    if parts.len() >= 2 {
                        if let Ok(n) = parts[0].parse::<i32>() {
                            outline_obj_num = Some(n);
                            break;
                        }
                    }
                }
            }
        }
    }

    let outline_num = match outline_obj_num {
        Some(n) => n,
        None => return Ok(None),
    };

    // Build a combined view for outline parsing (only outline-related objects)
    let mut combined = content.into_owned();
    for (&obj_num, obj_data) in &all_objects {
        combined.push_str(&format!("\n{} 0 obj\n{}\nendobj\n", obj_num, obj_data));
    }

    // Validate: referenced object must be /Type /Outlines with /First or /Last
    if !is_valid_outline_root(&combined, outline_num) {
        return Ok(None);
    }

    let bookmarks = parse_outline_tree(&combined, outline_num, &page_map)?;
    let max_page = page_map.values().max().map(|&i| i + 1).unwrap_or(0);
    if !bookmarks.is_empty() && bookmarks_reference_valid_pages(&bookmarks, max_page) {
        return Ok(Some(bookmarks));
    }

    Ok(None)
}

/// Find /Outlines only when it appears in a Catalog dictionary (not /Type /Outlines).
fn find_outlines_in_catalog(content: &str) -> Option<usize> {
    let catalog_pos = content
        .find("/Type /Catalog")
        .or_else(|| content.find("/Type/Catalog"))?;
    let end = (catalog_pos + 1024).min(content.len());
    let section = &content[catalog_pos..end];
    let outlines_pos = section.find("/Outlines")?;
    let abs_pos = catalog_pos + outlines_pos;
    // Ensure we're not matching /Type /Outlines (outline item type)
    if outlines_pos >= 6 && &section[outlines_pos.saturating_sub(6)..outlines_pos] == "/Type " {
        return None;
    }
    Some(abs_pos)
}

/// Verify the referenced object is a valid outline root: has /Type /Outlines and /First or /Last.
fn is_valid_outline_root(content: &str, outline_obj_num: i32) -> bool {
    let body = match extract_object_body(content, outline_obj_num) {
        Some(b) => b,
        None => return false,
    };
    let has_type_outlines = body.contains("/Type /Outlines") || body.contains("/Type/Outlines");
    let has_first_or_last = body.contains("/First") || body.contains("/Last");
    has_type_outlines && has_first_or_last
}

/// Return false if any bookmark references a page >= max_page.
fn bookmarks_reference_valid_pages(bookmarks: &[Bookmark], max_page: usize) -> bool {
    for b in bookmarks {
        if b.page >= max_page {
            return false;
        }
        if !bookmarks_reference_valid_pages(&b.children, max_page) {
            return false;
        }
    }
    true
}

/// Build a map from page object number to 0-based page index
fn build_page_object_map(content: &str) -> std::collections::HashMap<i32, usize> {
    let mut map = std::collections::HashMap::new();
    let mut page_idx = 0usize;

    let catalog_pos = content
        .find("/Type /Catalog")
        .or_else(|| content.find("/Type/Catalog"));

    if let Some(pos) = catalog_pos {
        let mut end = (pos + 1000).min(content.len());
        while end > pos && !content.is_char_boundary(end) {
            end -= 1;
        }
        let section = &content[pos..end];
        if let Some(pages_pos) = section.find("/Pages") {
            let after = &section[pages_pos + 6..];
            if let Some(root_pages_obj) = parse_obj_ref(after.trim_start()) {
                collect_pages_recursive(content, root_pages_obj, &mut map, &mut page_idx);
            }
        }
    }

    if map.is_empty() {
        let pages_pos = content
            .find("/Type /Pages")
            .or_else(|| content.find("/Type/Pages"));
        if let Some(pp) = pages_pos {
            let before = &content[..pp];
            if let Some(obj_start) = before.rfind(" 0 obj") {
                let line_start = before[..obj_start].rfind('\n').map(|p| p + 1).unwrap_or(0);
                if let Ok(obj_num) = before[line_start..obj_start].trim().parse::<i32>() {
                    collect_pages_recursive(content, obj_num, &mut map, &mut page_idx);
                }
            }
        }
    }

    map
}

/// Recursively collect page object numbers from the page tree
fn collect_pages_recursive(
    content: &str,
    node_obj_num: i32,
    map: &mut std::collections::HashMap<i32, usize>,
    page_idx: &mut usize,
) {
    let body = match extract_object_body(content, node_obj_num) {
        Some(b) => b,
        None => return,
    };

    let is_pages_node = body.contains("/Type /Pages")
        || body.contains("/Type/Pages")
        || (body.contains("/Kids") && !is_page_leaf(&body));
    if is_pages_node {
        // Intermediate /Pages node - recurse into /Kids
        if let Some(kids_pos) = body.find("/Kids") {
            let after_kids = &body[kids_pos + 5..];
            if let Some(bracket_start) = after_kids.find('[') {
                if let Some(bracket_end) = after_kids[bracket_start..].find(']') {
                    let kids_str = &after_kids[bracket_start + 1..bracket_start + bracket_end];
                    let tokens: Vec<&str> = kids_str.split_whitespace().collect();
                    let mut i = 0;
                    while i + 2 < tokens.len() {
                        if tokens[i + 2] == "R" {
                            if let Ok(kid_obj) = tokens[i].parse::<i32>() {
                                collect_pages_recursive(content, kid_obj, map, page_idx);
                            }
                        }
                        i += 3;
                    }
                }
            }
        }
    } else {
        // Leaf /Page node
        map.insert(node_obj_num, *page_idx);
        *page_idx += 1;
    }
}

/// Check whether an object body represents a /Page leaf (not /Pages).
fn is_page_leaf(body: &str) -> bool {
    for variant in ["/Type /Page", "/Type/Page"] {
        if let Some(pos) = body.find(variant) {
            let after = pos + variant.len();
            if after >= body.len() {
                return true;
            }
            let next = body.as_bytes()[after];
            if next != b's' && next != b'S' {
                return true;
            }
        }
    }
    false
}

/// Extract the object body (between `N 0 obj` and `endobj`).
/// Returns a borrowed slice to avoid allocating a new String per call.
fn extract_object_body(content: &str, obj_num: i32) -> Option<&str> {
    let marker = format!("{} 0 obj", obj_num);
    let mut search_pos = 0;

    while let Some(pos) = content[search_pos..].find(&marker) {
        let abs_pos = search_pos + pos;

        if abs_pos > 0 && content.as_bytes()[abs_pos - 1].is_ascii_digit() {
            search_pos = abs_pos + marker.len();
            continue;
        }

        let body_start = abs_pos + marker.len();
        if let Some(end_pos) = content[body_start..].find("endobj") {
            return Some(&content[body_start..body_start + end_pos]);
        }
        search_pos = abs_pos + marker.len();
    }
    None
}

/// Parse an indirect object reference (N 0 R) and return the object number.
/// Handles compact format where R may be followed by / with no whitespace (e.g. "2 0 R/Next").
fn parse_obj_ref(text: &str) -> Option<i32> {
    let parts: Vec<&str> = text.split_whitespace().take(3).collect();
    if parts.len() >= 3
        && (parts[2] == "R" || parts[2].starts_with("R/") || parts[2].starts_with("R>"))
    {
        parts[0].parse::<i32>().ok()
    } else {
        None
    }
}

fn parse_outline_tree(
    content: &str,
    outline_obj_num: i32,
    page_map: &std::collections::HashMap<i32, usize>,
) -> Result<Vec<Bookmark>> {
    let body = match extract_object_body(content, outline_obj_num) {
        Some(b) => b,
        None => return Ok(Vec::new()),
    };

    if let Some(first_pos) = body.find("/First") {
        let after_first = &body[first_pos + 6..];
        if let Some(first_obj) = parse_obj_ref(after_first.trim_start()) {
            return traverse_outline_items(content, first_obj, page_map);
        }
    }

    Ok(Vec::new())
}

fn traverse_outline_items(
    content: &str,
    first_obj_num: i32,
    page_map: &std::collections::HashMap<i32, usize>,
) -> Result<Vec<Bookmark>> {
    let mut bookmarks = Vec::new();
    let mut current_obj = Some(first_obj_num);

    while let Some(obj_num) = current_obj {
        let body = match extract_object_body(content, obj_num) {
            Some(b) => b,
            None => break,
        };

        let title = extract_title(&body).unwrap_or_default();
        let page_num = extract_page_dest(&body, page_map);

        let mut bookmark = Bookmark::new(title, page_num);

        if let Some(first_pos) = body.find("/First") {
            let after = &body[first_pos + 6..];
            if let Some(child_obj) = parse_obj_ref(after.trim_start()) {
                bookmark.children = traverse_outline_items(content, child_obj, page_map)?;
            }
        }

        bookmarks.push(bookmark);

        // Follow /Next sibling
        current_obj = None;
        if let Some(next_pos) = body.find("/Next") {
            let after = &body[next_pos + 5..];
            current_obj = parse_obj_ref(after.trim_start());
        }
    }

    Ok(bookmarks)
}

/// Extract title from an outline item object body
fn extract_title(body: &str) -> Option<String> {
    let title_pos = body.find("/Title")?;
    let after_title = &body[title_pos + 6..];
    let trimmed = after_title.trim_start();

    if trimmed.starts_with('(') {
        let mut depth = 0i32;
        let mut end_idx = None;
        let bytes = trimmed.as_bytes();
        let mut i = 0;
        while i < bytes.len() {
            match bytes[i] {
                b'\\' => {
                    i += 2; // skip escaped character
                    continue;
                }
                b'(' => depth += 1,
                b')' => {
                    depth -= 1;
                    if depth == 0 {
                        end_idx = Some(i);
                        break;
                    }
                }
                _ => {}
            }
            i += 1;
        }
        end_idx.map(|end| {
            let raw = &trimmed[1..end];
            raw.replace("\\(", "(")
                .replace("\\)", ")")
                .replace("\\\\", "\\")
        })
    } else {
        None
    }
}

/// Extract 0-based page index from /Dest, resolving object references
fn extract_page_dest(body: &str, page_map: &std::collections::HashMap<i32, usize>) -> usize {
    if let Some(dest_pos) = body.find("/Dest") {
        let after_dest = &body[dest_pos + 5..].trim_start();
        if let Some(bracket_start) = after_dest.find('[') {
            let after_bracket = &after_dest[bracket_start + 1..];
            if let Some(page_obj) = parse_obj_ref(after_bracket.trim_start()) {
                return page_map.get(&page_obj).copied().unwrap_or(0);
            }
        }
    }
    0
}

/// Create bookmark hierarchy from flat list
pub fn create_hierarchy(bookmarks: Vec<(String, usize, usize)>) -> Vec<Bookmark> {
    // bookmarks: (title, page, level)
    if bookmarks.is_empty() {
        return Vec::new();
    }

    let mut root_bookmarks = Vec::new();
    let mut stack: Vec<usize> = Vec::new(); // Stack of indices into root_bookmarks for tracking parents

    for (title, page, level) in bookmarks {
        let bookmark = Bookmark::new(title, page);

        if level == 0 {
            // Top-level bookmark
            root_bookmarks.push(bookmark);
            stack.clear();
            stack.push(root_bookmarks.len() - 1);
        } else {
            // Child bookmark - find parent at level-1
            while stack.len() > level {
                stack.pop();
            }

            if stack.len() == level {
                // Found correct parent level
                let mut current = &mut root_bookmarks;

                // Navigate to the parent bookmark
                for &idx in &stack[..stack.len() - 1] {
                    current = &mut current.get_mut(idx).unwrap().children;
                }

                if let Some(parent) = current.get_mut(*stack.last().unwrap()) {
                    parent.add_child(bookmark);
                    stack.push(parent.children.len() - 1);
                }
            }
        }
    }

    root_bookmarks
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::NamedTempFile;

    /// Returns a minimal valid PDF structure for testing
    fn minimal_valid_pdf() -> &'static [u8] {
        b"%PDF-1.7\n\
        1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
        2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n\
        3 0 obj\n<< /Type /Page /MediaBox [0 0 612 792] /Parent 2 0 R >>\nendobj\n\
        xref\n0 4\n\
        0000000000 65535 f \n\
        0000000009 00000 n \n\
        0000000058 00000 n \n\
        0000000115 00000 n \n\
        trailer\n<< /Size 4 /Root 1 0 R >>\n\
        startxref\n186\n%%EOF\n"
    }

    #[test]
    fn test_bookmark_new() {
        let bookmark = Bookmark::new("Chapter 1", 0);
        assert_eq!(bookmark.title, "Chapter 1");
        assert_eq!(bookmark.page, 0);
        assert!(bookmark.children.is_empty());
    }

    #[test]
    fn test_bookmark_add_child() {
        let mut parent = Bookmark::new("Part 1", 0);
        let child = Bookmark::new("Section 1.1", 5);
        parent.add_child(child);
        assert_eq!(parent.children.len(), 1);
    }

    #[test]
    fn test_bookmark_count_all() {
        let mut parent = Bookmark::new("Part 1", 0);
        parent.add_child(Bookmark::new("Section 1.1", 5));
        parent.add_child(Bookmark::new("Section 1.2", 10));
        assert_eq!(parent.count_all(), 3); // Parent + 2 children
    }

    #[test]
    fn test_bookmark_find_by_title() {
        let mut parent = Bookmark::new("Part 1", 0);
        parent.add_child(Bookmark::new("Section 1.1", 5));
        parent.add_child(Bookmark::new("Section 1.2", 10));

        assert!(parent.find_by_title("Section 1.1").is_some());
        assert!(parent.find_by_title("Nonexistent").is_none());
    }

    #[test]
    fn test_bookmark_validate_empty_title() {
        let bookmark = Bookmark::new("", 0);
        assert!(bookmark.validate(100).is_err());
    }

    #[test]
    fn test_bookmark_validate_title_too_long() {
        let bookmark = Bookmark::new("x".repeat(501), 0);
        assert!(bookmark.validate(100).is_err());
    }

    #[test]
    fn test_bookmark_validate_page_out_of_range() {
        let bookmark = Bookmark::new("Chapter", 100);
        assert!(bookmark.validate(50).is_err());
    }

    #[test]
    fn test_bookmark_validate_valid() {
        let bookmark = Bookmark::new("Chapter 1", 0);
        assert!(bookmark.validate(100).is_ok());
    }

    #[test]
    fn test_bookmark_validate_with_children() {
        let mut parent = Bookmark::new("Part 1", 0);
        parent.add_child(Bookmark::new("Section 1.1", 5));
        parent.add_child(Bookmark::new("Section 1.2", 150)); // Invalid page

        assert!(parent.validate(100).is_err());
    }

    #[test]
    fn test_add_bookmark_nonexistent_pdf() {
        let bookmark = Bookmark::new("Chapter 1", 0);
        let result = add_bookmark("/nonexistent/file.pdf", &bookmark);
        assert!(result.is_err());
    }

    #[test]
    fn test_add_bookmark_valid() -> Result<()> {
        let mut temp = NamedTempFile::new().map_err(|e| EnhancedError::Generic(e.to_string()))?;
        temp.write_all(minimal_valid_pdf())
            .map_err(|e| EnhancedError::Generic(e.to_string()))?;

        let path = temp.path().to_str().unwrap();
        let bookmark = Bookmark::new("Chapter 1", 0);

        let result = add_bookmark(path, &bookmark);
        assert!(result.is_ok());
        Ok(())
    }

    #[test]
    fn test_remove_bookmark_nonexistent_pdf() {
        let result = remove_bookmark("/nonexistent/file.pdf", "Chapter 1");
        assert!(result.is_err());
    }

    #[test]
    fn test_remove_bookmark_empty_title() -> Result<()> {
        let mut temp = NamedTempFile::new().map_err(|e| EnhancedError::Generic(e.to_string()))?;
        temp.write_all(b"%PDF-1.4\n")
            .map_err(|e| EnhancedError::Generic(e.to_string()))?;

        let path = temp.path().to_str().unwrap();
        let result = remove_bookmark(path, "");

        assert!(result.is_err());
        Ok(())
    }

    #[test]
    fn test_get_bookmarks_nonexistent_pdf() {
        let result = get_bookmarks("/nonexistent/file.pdf");
        assert!(result.is_err());
    }

    #[test]
    fn test_get_bookmarks_empty() -> Result<()> {
        let mut temp = NamedTempFile::new().map_err(|e| EnhancedError::Generic(e.to_string()))?;
        temp.write_all(minimal_valid_pdf())
            .map_err(|e| EnhancedError::Generic(e.to_string()))?;

        let path = temp.path().to_str().unwrap();
        let bookmarks = get_bookmarks(path)?;

        assert_eq!(bookmarks.len(), 0);
        Ok(())
    }

    #[test]
    fn test_create_hierarchy_flat() {
        let flat = vec![
            ("Chapter 1".to_string(), 0, 0),
            ("Chapter 2".to_string(), 10, 0),
        ];

        let hierarchy = create_hierarchy(flat);
        assert_eq!(hierarchy.len(), 2);
    }

    #[test]
    fn test_create_hierarchy_nested() {
        let flat = vec![
            ("Chapter 1".to_string(), 0, 0),
            ("Section 1.1".to_string(), 5, 1),
            ("Section 1.2".to_string(), 8, 1),
            ("Chapter 2".to_string(), 10, 0),
        ];

        let hierarchy = create_hierarchy(flat);
        assert_eq!(hierarchy.len(), 2);
        assert_eq!(hierarchy[0].children.len(), 2);
    }

    #[test]
    fn test_extract_object_body_no_partial_match() {
        let content = "13 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n\
                        3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources 10 0 R >>\nendobj\n";

        let body3 = super::extract_object_body(content, 3).unwrap();
        assert!(
            body3.contains("/Resources 10 0 R"),
            "Should find obj 3, not obj 13. Got: {}",
            body3
        );

        let body13 = super::extract_object_body(content, 13).unwrap();
        assert!(
            !body13.contains("/Resources"),
            "Should find obj 13. Got: {}",
            body13
        );
    }

    #[test]
    fn test_build_page_object_map_correct_ordering() {
        let content = "\
1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n\
2 0 obj\n<< /Type /Pages /Kids [3 0 R 7 0 R 11 0 R] /Count 3 >>\nendobj\n\
13 0 obj\n<< /Type /SomeOther >>\nendobj\n\
3 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n\
7 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n\
11 0 obj\n<< /Type /Page /Parent 2 0 R >>\nendobj\n";

        let map = super::build_page_object_map(content);
        assert_eq!(map.len(), 3);
        assert_eq!(map[&3], 0, "obj 3 -> page 0");
        assert_eq!(map[&7], 1, "obj 7 -> page 1");
        assert_eq!(map[&11], 2, "obj 11 -> page 2");
    }

    #[test]
    fn test_build_page_object_map_compact_format() {
        let content = "\
1 0 obj\n<</Type/Catalog/Pages 2 0 R/Outlines 10 0 R\n>>\nendobj\n\
2 0 obj\n<</Type/Pages/Count 3/Kids[3 0 R 7 0 R 11 0 R]>>\nendobj\n\
3 0 obj\n<</Type/Page/Parent 2 0 R>>\nendobj\n\
7 0 obj\n<</Type/Page/Parent 2 0 R>>\nendobj\n\
11 0 obj\n<</Type/Page/Parent 2 0 R>>\nendobj\n";

        let map = super::build_page_object_map(content);
        assert_eq!(map.len(), 3, "Expected 3 pages, got {:?}", map);
        assert_eq!(map[&3], 0);
        assert_eq!(map[&7], 1);
        assert_eq!(map[&11], 2);
    }

    #[test]
    fn test_parse_obj_ref_compact() {
        assert_eq!(super::parse_obj_ref("2 0 R"), Some(2));
        assert_eq!(super::parse_obj_ref("2 0 R/Outlines"), Some(2));
        assert_eq!(super::parse_obj_ref("2 0 R>>"), Some(2));
        assert_eq!(super::parse_obj_ref("not a ref"), None);
    }

    #[test]
    fn test_is_page_leaf() {
        assert!(super::is_page_leaf("/Type /Page\n/Parent 2 0 R"));
        assert!(super::is_page_leaf("/Type/Page\n/Parent 2 0 R"));
        assert!(super::is_page_leaf("/Type /Page /Parent 2 0 R"));
        assert!(!super::is_page_leaf("/Type /Pages /Kids [3 0 R]"));
        assert!(!super::is_page_leaf("/Type/Pages /Kids [3 0 R]"));
    }

    /// PDF with standard binary comment (%âãÏÓ) that causes U+FFFD replacement.
    /// This is the exact scenario from MPDF-2 where `build_page_object_map`
    /// panics on `&content[pos..end]` when `end` lands inside a U+FFFD sequence.
    fn binary_pdf_bytes() -> Vec<u8> {
        let mut data = Vec::new();
        data.extend_from_slice(b"%PDF-1.4\n%");
        data.extend_from_slice(&[0xE2, 0xE3, 0xCF, 0xD3]); // binary header: âãÏÓ
        data.extend_from_slice(b"\n");
        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
        data.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
        data.extend_from_slice(
            b"3 0 obj\n<< /Type /Page /MediaBox [0 0 612 792] /Parent 2 0 R >>\nendobj\n",
        );
        data.extend_from_slice(b"xref\n0 4\n");
        data.extend_from_slice(b"0000000000 65535 f \n");
        data.extend_from_slice(b"0000000015 00000 n \n");
        data.extend_from_slice(b"0000000064 00000 n \n");
        data.extend_from_slice(b"0000000121 00000 n \n");
        data.extend_from_slice(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
        data.extend_from_slice(b"startxref\n192\n%%EOF\n");
        data
    }

    #[test]
    fn test_build_page_object_map_binary_pdf_no_panic() {
        let data = binary_pdf_bytes();
        let content = String::from_utf8_lossy(&data);
        let map = super::build_page_object_map(&content);
        assert_eq!(map.len(), 1, "Expected 1 page in binary PDF");
        assert_eq!(map[&3], 0);
    }

    #[test]
    fn test_get_bookmarks_binary_pdf_no_panic() {
        let data = binary_pdf_bytes();
        let mut temp = NamedTempFile::new().expect("create tempfile");
        temp.write_all(&data).expect("write binary PDF");

        let path = temp.path().to_str().unwrap();
        let result = get_bookmarks(path);
        assert!(
            result.is_ok(),
            "get_bookmarks should not panic on binary PDF: {:?}",
            result.err()
        );
    }

    #[test]
    fn test_add_bookmark_binary_pdf_no_panic() {
        let data = binary_pdf_bytes();
        let mut temp = NamedTempFile::new().expect("create tempfile");
        temp.write_all(&data).expect("write binary PDF");

        let path = temp.path().to_str().unwrap();
        let bookmark = Bookmark::new("Chapter 1", 0);
        let result = add_bookmark(path, &bookmark);
        assert!(
            result.is_ok(),
            "add_bookmark should not panic on binary PDF: {:?}",
            result.err()
        );
    }

    #[test]
    fn test_scan_max_object_number() {
        let data = b"1 0 obj\n<</Type /Catalog>>\nendobj\n42 0 obj\n<</Type /Page>>\nendobj\n5 0 obj\n<<>>\nendobj\n";
        assert_eq!(super::scan_max_object_number(data), 42);
    }

    #[test]
    fn test_find_catalog_in_trailer_bytes() {
        let data = b"trailer\n<< /Size 4 /Root 7 0 R >>\nstartxref\n100\n%%EOF\n";
        let result = super::find_catalog_in_trailer_bytes(data);
        assert_eq!(result.unwrap(), 7);
    }

    #[test]
    fn test_build_page_object_map_many_binary_bytes_before_catalog() {
        let mut data = Vec::new();
        data.extend_from_slice(b"%PDF-1.4\n%");
        // 500 invalid bytes that become 1500 bytes of U+FFFD after lossy conversion
        for _ in 0..500 {
            data.push(0xFF);
        }
        data.extend_from_slice(b"\n");
        data.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
        data.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n");
        data.extend_from_slice(
            b"3 0 obj\n<< /Type /Page /MediaBox [0 0 612 792] /Parent 2 0 R >>\nendobj\n",
        );
        data.extend_from_slice(b"xref\n0 4\n");
        data.extend_from_slice(b"0000000000 65535 f \n");
        data.extend_from_slice(b"0000000516 00000 n \n");
        data.extend_from_slice(b"0000000565 00000 n \n");
        data.extend_from_slice(b"0000000622 00000 n \n");
        data.extend_from_slice(b"trailer\n<< /Size 4 /Root 1 0 R >>\n");
        data.extend_from_slice(b"startxref\n710\n%%EOF\n");

        let content = String::from_utf8_lossy(&data);
        let map = super::build_page_object_map(&content);
        assert_eq!(
            map.len(),
            1,
            "Should parse 1 page even with heavy binary content"
        );
    }
}
