//! High-level text extraction API - port of pdfminer.six high_level.py
//!
//! Provides the main public API for PDF text extraction:
//! - `extract_text()` - Extract all text from a PDF as a String
//! - `extract_text_to_fp()` - Extract text to a writer
//! - `extract_pages()` - Iterator over analyzed pages

use std::io::Write;

use rayon::ThreadPoolBuilder;
use rayon::prelude::*;

use crate::api::stream::{PageStream, extract_pages_stream_from_doc};
use crate::arena::PageArena;
use crate::converter::{PDFPageAggregator, PDFTableCollector, TextConverter};
use crate::error::{PdfError, Result};
use crate::image::ImageWriter;
use crate::layout::{LAParams, LTPage};
use crate::pdfdocument::{DEFAULT_CACHE_CAPACITY, PDFDocument};
use crate::pdfinterp::{PDFPageInterpreter, PDFResourceManager};
use crate::pdfpage::PDFPage;
use crate::table::edge_probe::{page_has_edges, should_skip_tables};
use crate::table::{
    PageGeometry, TableSettings, collect_table_objects_from_arena, extract_tables_from_objects,
};

use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
#[cfg(test)]
use std::sync::{Mutex, OnceLock};

#[cfg(test)]
#[derive(Clone, Copy)]
struct ThreadRecord {
    id: std::thread::ThreadId,
    in_pool: bool,
}

#[cfg(test)]
static THREAD_LOG: OnceLock<Mutex<Vec<ThreadRecord>>> = OnceLock::new();

#[cfg(test)]
fn record_thread() {
    let log = THREAD_LOG.get_or_init(|| Mutex::new(Vec::new()));
    if let Ok(mut guard) = log.lock() {
        let in_pool = rayon::current_thread_index().is_some();
        guard.push(ThreadRecord {
            id: std::thread::current().id(),
            in_pool,
        });
    }
}

#[cfg(not(test))]
fn record_thread() {}

#[cfg(test)]
fn take_thread_log() -> Vec<ThreadRecord> {
    let log = THREAD_LOG.get_or_init(|| Mutex::new(Vec::new()));
    let mut guard = log.lock().unwrap();
    std::mem::take(&mut *guard)
}

#[cfg(test)]
pub fn clear_thread_log() {
    let log = THREAD_LOG.get_or_init(|| Mutex::new(Vec::new()));
    let mut guard = log.lock().unwrap();
    guard.clear();
}

#[cfg(test)]
pub fn take_thread_log_len() -> usize {
    take_thread_log().len()
}

pub(crate) fn default_thread_count() -> usize {
    std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(1)
}

fn cache_capacity(caching: bool) -> usize {
    if caching { DEFAULT_CACHE_CAPACITY } else { 0 }
}

/// Options for text extraction.
///
/// Port of the various optional parameters from pdfminer.six high_level functions.
#[derive(Debug, Clone, PartialEq)]
pub struct ExtractOptions {
    /// Password for encrypted PDFs.
    pub password: String,

    /// Zero-indexed page numbers to extract. None means all pages.
    pub page_numbers: Option<Vec<usize>>,

    /// Maximum number of pages to extract. 0 means no limit.
    pub maxpages: usize,

    /// Whether to cache resources (fonts, images).
    pub caching: bool,

    /// Layout analysis parameters. None uses default LAParams.
    pub laparams: Option<LAParams>,
}

pub type Cell = Option<String>;
pub type Row = Vec<Cell>;
pub type Table = Vec<Row>;
pub type PageTables = Vec<Table>;
pub type DocumentTables = Vec<PageTables>;

impl Default for ExtractOptions {
    fn default() -> Self {
        Self {
            password: String::new(),
            page_numbers: None,
            maxpages: 0,
            caching: true,
            laparams: None,
        }
    }
}

/// Parse and return the text contained in PDF data.
///
/// This is the main text extraction function.
///
/// # Arguments
/// * `pdf_data` - PDF file contents as bytes
/// * `options` - Extraction options (None for defaults)
///
/// # Returns
/// A string containing all extracted text.
///
/// # Example
/// ```ignore
/// use bolivar_core::high_level::{extract_text, ExtractOptions};
///
/// let pdf_bytes = std::fs::read("document.pdf")?;
/// let text = extract_text(&pdf_bytes, None)?;
/// println!("{}", text);
/// ```
pub fn extract_text(pdf_data: &[u8], options: Option<ExtractOptions>) -> Result<String> {
    let options = options.unwrap_or_default();
    let doc =
        PDFDocument::new_with_cache(pdf_data, &options.password, cache_capacity(options.caching))?;
    extract_text_with_document(&doc, options)
}

/// Extract text from an already-parsed PDFDocument.
pub fn extract_text_with_document(doc: &PDFDocument, options: ExtractOptions) -> Result<String> {
    // Use LAParams or create default
    let laparams = options.laparams.clone().unwrap_or_default();

    // Create output buffer
    let mut output = Vec::new();

    extract_text_to_fp_from_doc_inner(
        doc,
        &mut output,
        options.page_numbers.as_deref(),
        options.maxpages,
        options.caching,
        Some(&laparams),
    )?;

    String::from_utf8(output).map_err(|e| PdfError::DecodeError(e.to_string()))
}

/// Parse text from PDF data and write to a writer.
///
/// # Arguments
/// * `pdf_data` - PDF file contents as bytes
/// * `writer` - Output writer for extracted text
/// * `options` - Extraction options (None for defaults)
///
/// # Example
/// ```ignore
/// use bolivar_core::high_level::{extract_text_to_fp, ExtractOptions};
/// use std::fs::File;
///
/// let pdf_bytes = std::fs::read("document.pdf")?;
/// let mut output = File::create("output.txt")?;
/// extract_text_to_fp(&pdf_bytes, &mut output, None)?;
/// ```
pub fn extract_text_to_fp<W: Write>(
    pdf_data: &[u8],
    writer: &mut W,
    options: Option<ExtractOptions>,
) -> Result<()> {
    let options = options.unwrap_or_default();

    let laparams = options.laparams.as_ref();

    extract_text_to_fp_inner(
        pdf_data,
        writer,
        &options.password,
        options.page_numbers.as_deref(),
        options.maxpages,
        options.caching,
        laparams,
    )
}

/// Inner implementation of extract_text_to_fp.
fn extract_text_to_fp_inner<W: Write>(
    pdf_data: &[u8],
    writer: &mut W,
    password: &str,
    page_numbers: Option<&[usize]>,
    maxpages: usize,
    caching: bool,
    laparams: Option<&LAParams>,
) -> Result<()> {
    // Validate PDF header
    if pdf_data.len() < 8 || !pdf_data.starts_with(b"%PDF-") {
        return Err(PdfError::SyntaxError("Invalid PDF header".to_string()));
    }

    // Parse PDF document
    let doc = PDFDocument::new_with_cache(pdf_data, password, cache_capacity(caching))?;
    extract_text_to_fp_from_doc_inner(&doc, writer, page_numbers, maxpages, caching, laparams)
}

fn extract_text_to_fp_from_doc_inner<W: Write>(
    doc: &PDFDocument,
    writer: &mut W,
    page_numbers: Option<&[usize]>,
    maxpages: usize,
    caching: bool,
    laparams: Option<&LAParams>,
) -> Result<()> {
    // Get LAParams (use default if not provided)
    let default_laparams = LAParams::default();
    let laparams = laparams.unwrap_or(&default_laparams);

    // Create text converter
    let mut converter = TextConverter::new(writer, "utf-8", 1, Some(laparams.clone()), false);

    let thread_count = default_thread_count();
    let pool = ThreadPoolBuilder::new()
        .num_threads(thread_count)
        .build()
        .map_err(|e| PdfError::DecodeError(e.to_string()))?;

    let mut selected_pages: Vec<(usize, PDFPage)> = Vec::new();
    let mut page_count = 0;
    for (page_idx, page_result) in PDFPage::create_pages(doc).enumerate() {
        if let Some(nums) = page_numbers
            && !nums.contains(&page_idx)
        {
            continue;
        }

        if maxpages > 0 && page_count >= maxpages {
            break;
        }

        let page = page_result?;
        selected_pages.push((page_idx, page));
        page_count += 1;
    }

    let mut results: Vec<(usize, Result<LTPage>)> = pool.install(|| {
        selected_pages
            .into_par_iter()
            .map_init(PageArena::new, |arena, (page_idx, page)| {
                arena.reset();
                let mut rsrcmgr = PDFResourceManager::with_caching(caching);
                let mut aggregator =
                    PDFPageAggregator::new(Some(laparams.clone()), page_idx as i32 + 1, arena);
                let ltpage = process_page(&page, &mut aggregator, &mut rsrcmgr, doc);
                (page_idx, ltpage)
            })
            .collect()
    });

    results.sort_by_key(|(page_idx, _)| *page_idx);
    for (_, result) in results {
        let ltpage = result?;
        converter.receive_layout(ltpage);
    }

    Ok(())
}

/// Process a PDF page and return its layout.
///
/// Uses PDFPageInterpreter to execute the page's content stream,
/// which populates the device (aggregator) with layout items.
pub(crate) fn process_page(
    page: &PDFPage,
    aggregator: &mut PDFPageAggregator<'_>,
    rsrcmgr: &mut PDFResourceManager,
    doc: &PDFDocument,
) -> Result<LTPage> {
    record_thread();

    // Create interpreter with resource manager and aggregator as device
    let mut interpreter = PDFPageInterpreter::new(rsrcmgr, aggregator);

    // Process page - this executes the content stream and populates the device
    interpreter.process_page(page, Some(doc));

    // Get the analyzed result from aggregator
    Ok(aggregator.get_result().clone())
}

pub(crate) fn process_page_arena<'a>(
    page: &PDFPage,
    collector: &mut PDFTableCollector<'a>,
    rsrcmgr: &mut PDFResourceManager,
    doc: &PDFDocument,
) -> Result<crate::arena::types::ArenaPage<'a>> {
    record_thread();

    let mut interpreter = PDFPageInterpreter::new(rsrcmgr, collector);
    interpreter.process_page(page, Some(doc));

    collector
        .take_result()
        .ok_or_else(|| PdfError::DecodeError("table collector produced no result".to_string()))
}

/// Iterator over analyzed pages.
///
/// Yields LTPage objects for each page in the PDF.
///
/// Note: Due to lifetime constraints with the underlying PDF document,
/// this iterator pre-processes pages into a vector. For very large PDFs,
/// consider using extract_text_to_fp with streaming output instead.
pub struct PageIterator {
    /// Pre-processed pages
    pages: std::vec::IntoIter<Result<LTPage>>,
}

impl Iterator for PageIterator {
    type Item = Result<LTPage>;

    fn next(&mut self) -> Option<Self::Item> {
        self.pages.next()
    }
}

impl PageIterator {
    /// Returns the number of remaining pages.
    pub fn len(&self) -> usize {
        self.pages.len()
    }

    /// Returns true if there are no more pages.
    pub fn is_empty(&self) -> bool {
        self.pages.len() == 0
    }
}

/// Extract and stream LTPage objects from PDF data in order.
///
/// Returns a PageStream that yields ordered LTPage results.
pub fn extract_pages_stream(
    pdf_data: &[u8],
    options: Option<ExtractOptions>,
) -> Result<PageStream> {
    let mut options = options.unwrap_or_default();
    if options.laparams.is_none() {
        options.laparams = Some(LAParams::default());
    }

    if pdf_data.len() < 8 || !pdf_data.starts_with(b"%PDF-") {
        return Err(PdfError::SyntaxError("Invalid PDF header".to_string()));
    }

    let doc =
        PDFDocument::new_with_cache(pdf_data, &options.password, cache_capacity(options.caching))?;
    extract_pages_stream_from_doc(Arc::new(doc), options)
}

/// Extract and yield LTPage objects from PDF data.
///
/// Returns an iterator over analyzed pages.
///
/// # Arguments
/// * `pdf_data` - PDF file contents as bytes
/// * `options` - Extraction options (None for defaults)
///
/// # Example
/// ```ignore
/// use bolivar_core::high_level::{extract_pages, ExtractOptions};
/// use bolivar_core::layout::LTTextBox;
///
/// let pdf_bytes = std::fs::read("document.pdf")?;
/// for page_result in extract_pages(&pdf_bytes, None)? {
///     let page = page_result?;
///     for item in page.iter() {
///         // Process layout items
///     }
/// }
/// ```
pub fn extract_pages(pdf_data: &[u8], options: Option<ExtractOptions>) -> Result<PageIterator> {
    let mut options = options.unwrap_or_default();
    // Match pdfminer.high_level.extract_pages default behavior.
    if options.laparams.is_none() {
        options.laparams = Some(LAParams::default());
    }

    // Validate PDF header
    if pdf_data.len() < 8 || !pdf_data.starts_with(b"%PDF-") {
        return Err(PdfError::SyntaxError("Invalid PDF header".to_string()));
    }

    let stream = extract_pages_stream(pdf_data, Some(options))?;
    let pages: Vec<Result<LTPage>> = stream.collect();

    Ok(PageIterator {
        pages: pages.into_iter(),
    })
}

/// Extract LTPage objects from an already-parsed PDFDocument.
pub fn extract_pages_with_document(
    doc: &PDFDocument,
    options: ExtractOptions,
) -> Result<Vec<LTPage>> {
    extract_pages_stream(doc.bytes(), Some(options))?.collect()
}

/// Extract LTPage objects from an already-parsed PDFDocument while exporting images.
pub fn extract_pages_with_images_with_document(
    doc: &PDFDocument,
    options: ExtractOptions,
    output_dir: &str,
) -> Result<Vec<LTPage>> {
    let image_writer = ImageWriter::new(output_dir)?;
    let image_writer = Rc::new(RefCell::new(image_writer));
    extract_pages_with_images_with_writer(doc, options, image_writer)
}

fn extract_pages_with_images_with_writer(
    doc: &PDFDocument,
    options: ExtractOptions,
    image_writer: Rc<RefCell<ImageWriter>>,
) -> Result<Vec<LTPage>> {
    let mut rsrcmgr = PDFResourceManager::with_caching(options.caching);
    let laparams = options.laparams.unwrap_or_default();
    let mut arena = PageArena::new();
    let mut pages = Vec::new();
    let mut page_count = 0;

    for (page_idx, page_result) in PDFPage::create_pages(doc).enumerate() {
        if let Some(ref nums) = options.page_numbers
            && !nums.contains(&page_idx)
        {
            continue;
        }

        if options.maxpages > 0 && page_count >= options.maxpages {
            break;
        }

        let page = page_result?;
        arena.reset();
        let mut aggregator = PDFPageAggregator::new_with_imagewriter(
            Some(laparams.clone()),
            page_idx as i32 + 1,
            Some(image_writer.clone()),
            &mut arena,
        );

        let ltpage = process_page(&page, &mut aggregator, &mut rsrcmgr, doc)?;
        pages.push(ltpage);
        page_count += 1;
    }

    Ok(pages)
}

/// Extract tables from an already-parsed PDFDocument.
pub fn extract_tables_with_document(
    doc: &PDFDocument,
    options: ExtractOptions,
    settings: &TableSettings,
) -> Result<DocumentTables> {
    let mut options = options;
    if options.laparams.is_none() {
        options.laparams = Some(LAParams::default());
    }
    let order = build_page_order(doc, &options);
    let laparams = options.laparams.clone();
    let caching = options.caching;
    let thread_count = default_thread_count();
    let pool = ThreadPoolBuilder::new()
        .num_threads(thread_count)
        .build()
        .map_err(|e| PdfError::DecodeError(e.to_string()))?;

    let mut results: Vec<(usize, PageTables)> = pool.install(|| {
        order
            .par_iter()
            .map(|&page_idx| {
                let page = PDFPage::get_page_by_index(doc, page_idx)?;
                let has_edges = page_has_edges(&page, doc, caching);
                if should_skip_tables(settings, has_edges) {
                    return Ok((page_idx, Vec::new()));
                }
                let mut arena = PageArena::new();
                arena.reset();
                let mut rsrcmgr = PDFResourceManager::with_caching(caching);
                let mut collector =
                    PDFTableCollector::new(laparams.clone(), page_idx as i32 + 1, &mut arena);
                let page_arena = process_page_arena(&page, &mut collector, &mut rsrcmgr, doc)?;
                let arena_lookup = collector.arena_lookup();
                let geom = PageGeometry {
                    page_bbox: page_arena.bbox,
                    mediabox: page_arena.bbox,
                    initial_doctop: 0.0,
                    force_crop: false,
                };
                let (chars, edges) = collect_table_objects_from_arena(&page_arena, &geom);
                Ok((
                    page_idx,
                    extract_tables_from_objects(chars, edges, &geom, settings, arena_lookup),
                ))
            })
            .collect::<Result<Vec<_>>>()
    })?;

    results.sort_by_key(|(idx, _)| *idx);
    Ok(results.into_iter().map(|(_, tables)| tables).collect())
}

/// Extract tables from an already-parsed PDFDocument using per-page geometry.
pub fn extract_tables_with_document_geometries(
    doc: &PDFDocument,
    options: ExtractOptions,
    settings: &TableSettings,
    geometries: &[PageGeometry],
) -> Result<DocumentTables> {
    let mut options = options;
    if options.laparams.is_none() {
        options.laparams = Some(LAParams::default());
    }
    let order = build_page_order(doc, &options);
    if geometries.len() != order.len() {
        return Err(PdfError::DecodeError(format!(
            "geometry count mismatch: expected {}, got {}",
            order.len(),
            geometries.len()
        )));
    }
    let laparams = options.laparams.clone();
    let caching = options.caching;
    let thread_count = default_thread_count();
    let pool = ThreadPoolBuilder::new()
        .num_threads(thread_count)
        .build()
        .map_err(|e| PdfError::DecodeError(e.to_string()))?;

    let mut results: Vec<(usize, PageTables)> = pool.install(|| {
        order
            .par_iter()
            .enumerate()
            .map(|(idx, &page_idx)| {
                let page = PDFPage::get_page_by_index(doc, page_idx)?;
                let has_edges = page_has_edges(&page, doc, caching);
                if should_skip_tables(settings, has_edges) {
                    return Ok((idx, Vec::new()));
                }
                let mut arena = PageArena::new();
                arena.reset();
                let mut rsrcmgr = PDFResourceManager::with_caching(caching);
                let mut collector =
                    PDFTableCollector::new(laparams.clone(), page_idx as i32 + 1, &mut arena);
                let page_arena = process_page_arena(&page, &mut collector, &mut rsrcmgr, doc)?;
                let arena_lookup = collector.arena_lookup();
                let geom = &geometries[idx];
                let (chars, edges) = collect_table_objects_from_arena(&page_arena, geom);
                Ok((
                    idx,
                    extract_tables_from_objects(chars, edges, geom, settings, arena_lookup),
                ))
            })
            .collect::<Result<Vec<_>>>()
    })?;

    results.sort_by_key(|(idx, _)| *idx);
    Ok(results.into_iter().map(|(_, tables)| tables).collect())
}

fn build_page_order(doc: &PDFDocument, options: &ExtractOptions) -> Vec<usize> {
    let mut order = Vec::new();
    let page_count = doc.page_index().len();
    let mut selected = 0usize;

    for page_idx in 0..page_count {
        if let Some(ref nums) = options.page_numbers
            && !nums.contains(&page_idx)
        {
            continue;
        }
        if options.maxpages > 0 && selected >= options.maxpages {
            break;
        }
        order.push(page_idx);
        selected += 1;
    }

    order
}

/// Extract tables for specific pages with per-page geometry in input order.
/// Extract tables for a single page using indexed page lookup.
pub fn extract_tables_for_page_indexed(
    doc: &PDFDocument,
    page_index: usize,
    geometry: &PageGeometry,
    mut options: ExtractOptions,
    settings: &TableSettings,
) -> Result<PageTables> {
    if options.laparams.is_none() {
        options.laparams = Some(LAParams::default());
    }
    let laparams = options.laparams.clone();
    let caching = options.caching;
    let mut arena = PageArena::new();
    arena.reset();
    let mut rsrcmgr = PDFResourceManager::with_caching(caching);
    let page = PDFPage::get_page_by_index(doc, page_index)?;
    let mut collector = PDFTableCollector::new(laparams, page_index as i32 + 1, &mut arena);
    let page_arena = process_page_arena(&page, &mut collector, &mut rsrcmgr, doc)?;
    let arena_lookup = collector.arena_lookup();
    let (chars, edges) = collect_table_objects_from_arena(&page_arena, geometry);
    Ok(extract_tables_from_objects(
        chars,
        edges,
        geometry,
        settings,
        arena_lookup,
    ))
}

#[cfg(test)]
mod tests {
    use super::{
        ExtractOptions, extract_pages, extract_tables_with_document,
        extract_tables_with_document_geometries,
    };
    use crate::pdfdocument::PDFDocument;
    use crate::table::{PageGeometry, TableProbePolicy, TableSettings};
    use std::collections::HashSet;
    use std::sync::{Mutex, OnceLock};

    static THREAD_LOG_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

    fn thread_log_guard() -> std::sync::MutexGuard<'static, ()> {
        THREAD_LOG_TEST_LOCK
            .get_or_init(|| Mutex::new(()))
            .lock()
            .expect("thread log test lock")
    }

    fn build_minimal_pdf_with_pages(page_count: usize) -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(b"%PDF-1.4\n");

        let mut offsets: Vec<usize> = Vec::new();
        let push_obj = |buf: &mut Vec<u8>, obj: String, offsets: &mut Vec<usize>| {
            offsets.push(buf.len());
            buf.extend_from_slice(obj.as_bytes());
        };

        // 1: Catalog
        push_obj(
            &mut out,
            "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_string(),
            &mut offsets,
        );

        // 2: Pages
        let kids: String = (0..page_count)
            .map(|i| format!("{} 0 R", 3 + i))
            .collect::<Vec<_>>()
            .join(" ");
        push_obj(
            &mut out,
            format!(
                "2 0 obj\n<< /Type /Pages /Kids [{}] /Count {} >>\nendobj\n",
                kids, page_count
            ),
            &mut offsets,
        );

        // Page objects and their content streams
        for i in 0..page_count {
            let page_id = 3 + i;
            let contents_id = 3 + page_count + i;
            push_obj(
                &mut out,
                format!(
                    "{} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents {} 0 R >>\nendobj\n",
                    page_id, contents_id
                ),
                &mut offsets,
            );
        }

        for i in 0..page_count {
            let contents_id = 3 + page_count + i;
            push_obj(
                &mut out,
                format!(
                    "{} 0 obj\n<< /Length 0 >>\nstream\n\nendstream\nendobj\n",
                    contents_id
                ),
                &mut offsets,
            );
        }

        let xref_pos = out.len();
        let obj_count = offsets.len();
        out.extend_from_slice(
            format!("xref\n0 {}\n0000000000 65535 f \n", obj_count + 1).as_bytes(),
        );
        for offset in offsets {
            out.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
        }
        out.extend_from_slice(b"trailer\n<< /Size ");
        out.extend_from_slice((obj_count + 1).to_string().as_bytes());
        out.extend_from_slice(b" /Root 1 0 R >>\nstartxref\n");
        out.extend_from_slice(xref_pos.to_string().as_bytes());
        out.extend_from_slice(b"\n%%EOF");

        out
    }

    fn build_table_pdf_with_text() -> Vec<u8> {
        let mut out = Vec::new();
        out.extend_from_slice(b"%PDF-1.4\n");

        let mut offsets: Vec<usize> = Vec::new();
        let push_obj = |buf: &mut Vec<u8>, obj: String, offsets: &mut Vec<usize>| {
            offsets.push(buf.len());
            buf.extend_from_slice(obj.as_bytes());
        };

        // 1: Catalog
        push_obj(
            &mut out,
            "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n".to_string(),
            &mut offsets,
        );

        // 2: Pages
        push_obj(
            &mut out,
            "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n".to_string(),
            &mut offsets,
        );

        // 3: Page with font + contents
        push_obj(
            &mut out,
            "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>\nendobj\n".to_string(),
            &mut offsets,
        );

        let stream = "0 0 0 RG\n0 0 0 rg\n1 w\n0 0 100 50 re S\n50 0 m 50 50 l S\nBT /F1 12 Tf 10 20 Td (Total) Tj ET\n";
        push_obj(
            &mut out,
            format!(
                "4 0 obj\n<< /Length {} >>\nstream\n{}\nendstream\nendobj\n",
                stream.len(),
                stream
            ),
            &mut offsets,
        );

        // 5: Font
        push_obj(
            &mut out,
            "5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n".to_string(),
            &mut offsets,
        );

        let xref_pos = out.len();
        let obj_count = offsets.len();
        out.extend_from_slice(
            format!("xref\n0 {}\n0000000000 65535 f \n", obj_count + 1).as_bytes(),
        );
        for offset in offsets {
            out.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
        }
        out.extend_from_slice(b"trailer\n<< /Size ");
        out.extend_from_slice((obj_count + 1).to_string().as_bytes());
        out.extend_from_slice(b" /Root 1 0 R >>\nstartxref\n");
        out.extend_from_slice(xref_pos.to_string().as_bytes());
        out.extend_from_slice(b"\n%%EOF");

        out
    }

    #[test]
    fn test_extract_pages_uses_rayon_pool() {
        let _guard = thread_log_guard();
        let pdf_data = build_minimal_pdf_with_pages(4);

        super::clear_thread_log();

        let options = ExtractOptions::default();
        let pages: Vec<_> = extract_pages(&pdf_data, Some(options))
            .unwrap()
            .collect::<Result<Vec<_>, _>>()
            .unwrap();

        assert_eq!(pages.len(), 4);

        let records = super::take_thread_log();
        let used_pool = records.iter().any(|record| record.in_pool);
        assert!(used_pool, "expected rayon pool to be used");

        let unique: HashSet<_> = records.iter().map(|record| record.id).collect();
        assert!(!unique.is_empty(), "expected at least one recorded thread");
    }

    #[test]
    fn test_extract_pages_uses_stream_path() {
        let _guard = crate::api::stream::stream_usage_test_guard();
        let pdf_data = build_minimal_pdf_with_pages(2);
        crate::api::stream::set_stream_usage_enabled(true);
        crate::api::stream::take_stream_usage();

        let options = ExtractOptions::default();
        let pages: Vec<_> = extract_pages(&pdf_data, Some(options))
            .unwrap()
            .collect::<Result<Vec<_>, _>>()
            .unwrap();

        assert_eq!(pages.len(), 2);
        let usage = crate::api::stream::take_stream_usage();
        crate::api::stream::set_stream_usage_enabled(false);
        assert!(usage >= 1);
    }

    #[test]
    fn test_extract_tables_with_document_parallel_ordered() {
        let pdf_data = build_minimal_pdf_with_pages(3);
        let doc = PDFDocument::new(&pdf_data, "").unwrap();
        let options = ExtractOptions::default();
        let settings = TableSettings::default();

        let tables = extract_tables_with_document(&doc, options, &settings).unwrap();
        assert_eq!(tables.len(), 3);
    }

    #[test]
    fn test_extract_tables_with_document_geometries_length_mismatch() {
        let pdf_data = build_minimal_pdf_with_pages(2);
        let doc = PDFDocument::new(&pdf_data, "").unwrap();
        let options = ExtractOptions::default();
        let settings = TableSettings::default();
        let geom = PageGeometry {
            page_bbox: (0.0, 0.0, 200.0, 200.0),
            mediabox: (0.0, 0.0, 200.0, 200.0),
            initial_doctop: 0.0,
            force_crop: false,
        };

        let err =
            extract_tables_with_document_geometries(&doc, options, &settings, &[geom]).unwrap_err();
        assert!(err.to_string().contains("geometry"));
    }

    #[test]
    fn table_probe_policy_controls_skip() {
        let pdf_data = build_minimal_pdf_with_pages(1);
        let doc = PDFDocument::new(&pdf_data, "").unwrap();
        let options = ExtractOptions::default();
        let settings = TableSettings {
            probe_policy: TableProbePolicy::Always,
            ..Default::default()
        };

        crate::layout::table::edge_probe::take_probe_calls();
        let out = extract_tables_with_document(&doc, options, &settings).unwrap();
        assert_eq!(out.len(), 1);
        let calls = crate::layout::table::edge_probe::take_probe_calls();
        assert!(calls > 0);
    }

    #[test]
    fn table_text_output_matches_before() {
        let pdf_data = build_table_pdf_with_text();
        let doc = PDFDocument::new(&pdf_data, "").unwrap();
        let options = ExtractOptions::default();
        let settings = TableSettings::default();

        let out = extract_tables_with_document(&doc, options, &settings).unwrap();
        let found = out
            .iter()
            .flatten()
            .flatten()
            .flatten()
            .any(|c| c.as_deref() == Some("Total"));
        assert!(found, "tables: {:?}", out);
    }
}
