// This file is auto-generated by alef. DO NOT EDIT.
// alef:hash:4e15143f4af1ae8bafbdb1506ef057da924484c66a19483966333558ad437e75
// Re-generate with: alef generate
#![allow(dead_code, unused_imports, unused_variables)]
#![allow(unsafe_code)]
#![allow(non_snake_case)]
#![allow(
    clippy::too_many_arguments,
    clippy::let_unit_value,
    clippy::needless_borrow,
    clippy::map_identity,
    clippy::just_underscores_and_digits,
    clippy::unnecessary_cast,
    clippy::unused_unit,
    clippy::unwrap_or_default,
    clippy::derivable_impls,
    clippy::needless_borrows_for_generic_args,
    clippy::unnecessary_fallible_conversions,
    clippy::arc_with_non_send_sync,
    clippy::collapsible_if,
    clippy::clone_on_copy,
    clippy::should_implement_trait,
    clippy::useless_conversion
)]
#![cfg_attr(windows, feature(abi_vectorcall))]

use ext_php_rs::prelude::*;
use ext_php_rs::rc::PhpRc;
use std::collections::HashMap;
use std::sync::Arc;

#[derive(Debug, Clone, Default)]
pub struct PhpBytes(pub Vec<u8>);

impl<'a> ext_php_rs::convert::FromZval<'a> for PhpBytes {
    const TYPE: ext_php_rs::flags::DataType = ext_php_rs::flags::DataType::String;
    fn from_zval(zval: &'a ext_php_rs::types::Zval) -> Option<Self> {
        zval.zend_str().map(|zs| PhpBytes(zs.as_bytes().to_vec()))
    }
}

impl From<PhpBytes> for Vec<u8> {
    fn from(b: PhpBytes) -> Self {
        b.0
    }
}

impl From<Vec<u8>> for PhpBytes {
    fn from(v: Vec<u8>) -> Self {
        PhpBytes(v)
    }
}

static WORKER_RUNTIME: std::sync::LazyLock<tokio::runtime::Runtime> = std::sync::LazyLock::new(|| {
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("Failed to create Tokio runtime")
});

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\CacheStats")]
#[serde(default, rename_all = "camelCase")]
pub struct CacheStats {
    #[php(prop, name = "totalFiles")]
    pub total_files: i64,
    #[php(prop, name = "totalSizeMb")]
    pub total_size_mb: f64,
    #[php(prop, name = "availableSpaceMb")]
    pub available_space_mb: f64,
    #[php(prop, name = "oldestFileAgeDays")]
    pub oldest_file_age_days: f64,
    #[php(prop, name = "newestFileAgeDays")]
    pub newest_file_age_days: f64,
}

#[php_impl]
impl CacheStats {
    #[php(constructor)]
    pub fn new(
        totalFiles: i64,
        totalSizeMb: f64,
        availableSpaceMb: f64,
        oldestFileAgeDays: f64,
        newestFileAgeDays: f64,
    ) -> Self {
        Self {
            total_files: totalFiles,
            total_size_mb: totalSizeMb,
            available_space_mb: availableSpaceMb,
            oldest_file_age_days: oldestFileAgeDays,
            newest_file_age_days: newestFileAgeDays,
        }
    }

    pub fn get_total_files(&self) -> i64 {
        self.total_files.clone()
    }

    pub fn get_total_size_mb(&self) -> f64 {
        self.total_size_mb.clone()
    }

    pub fn get_available_space_mb(&self) -> f64 {
        self.available_space_mb.clone()
    }

    pub fn get_oldest_file_age_days(&self) -> f64 {
        self.oldest_file_age_days.clone()
    }

    pub fn get_newest_file_age_days(&self) -> f64 {
        self.newest_file_age_days.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\AccelerationConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct AccelerationConfig {
    /// Execution provider to use for ONNX inference.
    #[php(prop, name = "provider")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub provider: String,
    /// GPU device ID (for CUDA/TensorRT). Ignored for CPU/CoreML/Auto.
    #[php(prop, name = "deviceId")]
    pub device_id: u32,
}

impl Default for AccelerationConfig {
    fn default() -> Self {
        <kreuzberg::AccelerationConfig as Default>::default().into()
    }
}

#[php_impl]
impl AccelerationConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(provider: String, deviceId: u32) -> Self {
        Self {
            provider,
            device_id: deviceId,
        }
    }

    pub fn get_provider(&self) -> String {
        self.provider.clone()
    }

    pub fn get_device_id(&self) -> u32 {
        self.device_id.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ContentFilterConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct ContentFilterConfig {
    /// Include running headers in extraction output.
    ///
    /// - PDF: Disables top-margin furniture stripping and prevents the layout
    ///   model from treating `PageHeader`-classified regions as furniture.
    /// - DOCX: Includes document headers in text output.
    /// - RTF/ODT: Headers already included; this is a no-op when true.
    /// - HTML/EPUB: Keeps `<header>` element content.
    ///
    /// Default: `false` (headers are stripped or excluded).
    #[php(prop, name = "includeHeaders")]
    pub include_headers: bool,
    /// Include running footers in extraction output.
    ///
    /// - PDF: Disables bottom-margin furniture stripping and prevents the layout
    ///   model from treating `PageFooter`-classified regions as furniture.
    /// - DOCX: Includes document footers in text output.
    /// - RTF/ODT: Footers already included; this is a no-op when true.
    /// - HTML/EPUB: Keeps `<footer>` element content.
    ///
    /// Default: `false` (footers are stripped or excluded).
    #[php(prop, name = "includeFooters")]
    pub include_footers: bool,
    /// Enable the heuristic cross-page repeating text detector.
    ///
    /// When `true` (default), text that repeats verbatim across a supermajority
    /// of pages is classified as furniture and stripped.  Disable this if brand
    /// names or repeated headings are being incorrectly removed by the heuristic.
    ///
    /// Note: when a layout-detection model is active, the model may independently
    /// classify page-header / page-footer regions as furniture on a per-page basis.
    /// To preserve those regions, set `include_headers = true`, `include_footers = true`,
    /// or both, in addition to disabling this flag.
    ///
    /// Primarily affects PDF extraction.
    ///
    /// Default: `true`.
    #[php(prop, name = "stripRepeatingText")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub strip_repeating_text: bool,
    /// Include watermark text in extraction output.
    ///
    /// - PDF: Keeps watermark artifacts and arXiv identifiers.
    /// - Other formats: No effect currently.
    ///
    /// Default: `false` (watermarks are stripped).
    #[php(prop, name = "includeWatermarks")]
    pub include_watermarks: bool,
}

impl Default for ContentFilterConfig {
    fn default() -> Self {
        <kreuzberg::ContentFilterConfig as Default>::default().into()
    }
}

#[php_impl]
impl ContentFilterConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(includeHeaders: bool, includeFooters: bool, stripRepeatingText: bool, includeWatermarks: bool) -> Self {
        Self {
            include_headers: includeHeaders,
            include_footers: includeFooters,
            strip_repeating_text: stripRepeatingText,
            include_watermarks: includeWatermarks,
        }
    }

    pub fn get_include_headers(&self) -> bool {
        self.include_headers.clone()
    }

    pub fn get_include_footers(&self) -> bool {
        self.include_footers.clone()
    }

    pub fn get_strip_repeating_text(&self) -> bool {
        self.strip_repeating_text.clone()
    }

    pub fn get_include_watermarks(&self) -> bool {
        self.include_watermarks.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> ContentFilterConfig {
        kreuzberg::ContentFilterConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\EmailConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct EmailConfig {
    /// Windows codepage number to use when an MSG file contains no codepage property.
    /// Defaults to `None`, which falls back to windows-1252.
    ///
    /// If an unrecognized or invalid codepage number is supplied (including 0),
    /// the behavior silently falls back to windows-1252 — the same as when the
    /// MSG file itself contains an unrecognized codepage. No error or warning is
    /// emitted. Users should verify output when supplying unusual values.
    ///
    /// Common values:
    /// - 1250: Central European (Polish, Czech, Hungarian, etc.)
    /// - 1251: Cyrillic (Russian, Ukrainian, Bulgarian, etc.)
    /// - 1252: Western European (default)
    /// - 1253: Greek
    /// - 1254: Turkish
    /// - 1255: Hebrew
    /// - 1256: Arabic
    /// - 932:  Japanese (Shift-JIS)
    /// - 936:  Simplified Chinese (GBK)
    #[php(prop, name = "msgFallbackCodepage")]
    pub msg_fallback_codepage: Option<u32>,
}

impl Default for EmailConfig {
    fn default() -> Self {
        <kreuzberg::EmailConfig as Default>::default().into()
    }
}

#[php_impl]
impl EmailConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_msg_fallback_codepage(&self) -> Option<u32> {
        self.msg_fallback_codepage.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ExtractionConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct ExtractionConfig {
    /// Enable caching of extraction results
    #[php(prop, name = "useCache")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub use_cache: bool,
    /// Enable quality post-processing
    #[php(prop, name = "enableQualityProcessing")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enable_quality_processing: bool,
    /// OCR configuration (None = OCR disabled)
    pub ocr: Option<OcrConfig>,
    /// Force OCR even for searchable PDFs
    #[php(prop, name = "forceOcr")]
    pub force_ocr: bool,
    /// Force OCR on specific pages only (1-indexed page numbers, must be >= 1).
    ///
    /// When set, only the listed pages are OCR'd regardless of text layer quality.
    /// Unlisted pages use native text extraction. Ignored when `force_ocr` is `true`.
    /// Only applies to PDF documents. Duplicates are automatically deduplicated.
    /// An `ocr` config is recommended for backend/language selection; defaults are used if absent.
    #[php(prop, name = "forceOcrPages")]
    pub force_ocr_pages: Option<Vec<u32>>,
    /// Disable OCR entirely, even for images.
    ///
    /// When `true`, OCR is skipped for all document types. Images return metadata
    /// only (dimensions, format, EXIF) without text extraction. PDFs use only
    /// native text extraction without OCR fallback.
    ///
    /// Cannot be `true` simultaneously with `force_ocr`.
    ///
    /// *Added in v4.7.0.*
    #[php(prop, name = "disableOcr")]
    pub disable_ocr: bool,
    /// Text chunking configuration (None = chunking disabled)
    pub chunking: Option<ChunkingConfig>,
    /// Content filtering configuration (None = use extractor defaults).
    ///
    /// Controls whether document "furniture" (headers, footers, watermarks,
    /// repeating text) is included in or stripped from extraction results.
    /// See [`ContentFilterConfig`] for per-field documentation.
    pub content_filter: Option<ContentFilterConfig>,
    /// Image extraction configuration (None = no image extraction)
    pub images: Option<ImageExtractionConfig>,
    /// PDF-specific options (None = use defaults)
    pub pdf_options: Option<PdfConfig>,
    /// Token reduction configuration (None = no token reduction)
    pub token_reduction: Option<TokenReductionOptions>,
    /// Language detection configuration (None = no language detection)
    pub language_detection: Option<LanguageDetectionConfig>,
    /// Page extraction configuration (None = no page tracking)
    pub pages: Option<PageConfig>,
    /// Keyword extraction configuration (None = no keyword extraction)
    pub keywords: Option<KeywordConfig>,
    /// Post-processor configuration (None = use defaults)
    pub postprocessor: Option<PostProcessorConfig>,
    /// HTML to Markdown conversion options (None = use defaults)
    ///
    /// Configure how HTML documents are converted to Markdown, including heading styles,
    /// list formatting, code block styles, and preprocessing options.
    #[php(prop, name = "htmlOptions")]
    #[serde(skip)]
    pub html_options: Option<String>,
    /// Styled HTML output configuration.
    ///
    /// When set alongside `output_format = OutputFormat::Html`, the extraction
    /// pipeline uses [`StyledHtmlRenderer`](crate::rendering::StyledHtmlRenderer)
    /// which emits stable `kb-*` CSS class hooks on every structural element
    /// and optionally embeds theme CSS or user-supplied CSS in a `<style>` block.
    ///
    /// When `None`, the existing plain comrak-based HTML renderer is used.
    pub html_output: Option<HtmlOutputConfig>,
    /// Default per-file timeout in seconds for batch extraction.
    ///
    /// When set, each file in a batch will be canceled after this duration
    /// unless overridden by [`FileExtractionConfig::timeout_secs`].
    ///
    /// Defaults to `Some(60)` to prevent pathological files (e.g. deeply
    /// nested archives, documents with millions of cells) from running
    /// indefinitely and exhausting caller resources. Set to `None` to
    /// disable the timeout for trusted input or long-running workloads.
    #[php(prop, name = "extractionTimeoutSecs")]
    pub extraction_timeout_secs: Option<i64>,
    /// Maximum concurrent extractions in batch operations (None = (num_cpus × 1.5).ceil()).
    ///
    /// Limits parallelism to prevent resource exhaustion when processing
    /// large batches. Defaults to (num_cpus × 1.5).ceil() when not set.
    #[php(prop, name = "maxConcurrentExtractions")]
    pub max_concurrent_extractions: Option<i64>,
    /// Result structure format
    ///
    /// Controls whether results are returned in unified format (default) with all
    /// content in the `content` field, or element-based format with semantic
    /// elements (for Unstructured-compatible output).
    #[php(prop, name = "resultFormat")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub result_format: String,
    /// Security limits for archive extraction.
    ///
    /// Controls maximum archive size, compression ratio, file count, and other
    /// security thresholds to prevent decompression bomb attacks. Also caps
    /// nesting depth, iteration count, entity / token length, total
    /// content size, and table cell count for every extraction path that
    /// ingests user-controlled bytes.
    /// When `None`, default limits are used.
    pub security_limits: Option<SecurityLimits>,
    /// Maximum uncompressed size in bytes for a single embedded file before
    /// recursive extraction is attempted (default: 50 MiB).
    ///
    /// Applies to embedded objects inside OOXML containers (DOCX, PPTX) and
    /// to email attachments processed via recursive extraction. Files that
    /// exceed this limit are skipped with a `ProcessingWarning` rather than
    /// passed to the extraction pipeline, preventing a single oversized
    /// embedded object from consuming unbounded memory or time.
    ///
    /// Set to `None` to disable the per-embedded-file cap (falls back to
    /// `security_limits.max_archive_size` as the only guard).
    #[php(prop, name = "maxEmbeddedFileBytes")]
    pub max_embedded_file_bytes: Option<i64>,
    /// Content text format (default: Plain).
    ///
    /// Controls the format of the extracted content:
    /// - `Plain`: Raw extracted text (default)
    /// - `Markdown`: Markdown formatted output
    /// - `Djot`: Djot markup format (requires djot feature)
    /// - `Html`: HTML formatted output
    ///
    /// When set to a structured format, extraction results will include
    /// formatted output. The `formatted_content` field may be populated
    /// when format conversion is applied.
    #[php(prop, name = "outputFormat")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub output_format: String,
    /// Layout detection configuration (None = layout detection disabled).
    ///
    /// When set, PDF pages and images are analyzed for document structure
    /// (headings, code, formulas, tables, figures, etc.) using RT-DETR models
    /// via ONNX Runtime. For PDFs, layout hints override paragraph classification
    /// in the markdown pipeline. For images, per-region OCR is performed with
    /// markdown formatting based on detected layout classes.
    /// Requires the `layout-detection` feature to run inference; the field is
    /// present whenever the `layout-types` feature is active (which includes
    /// `layout-detection` as well as the no-ORT target groups).
    pub layout: Option<LayoutDetectionConfig>,
    /// Run layout detection on the non-OCR PDF markdown path.
    ///
    /// When `true` and `layout` is `Some(_)`, layout regions inform heading,
    /// table, list, and figure detection in the structure pipeline that would
    /// otherwise rely on font-clustering heuristics alone. Significantly
    /// improves SF1 (structural F1) at the cost of inference latency
    /// (~150-300ms/page CPU, ~20-50ms/page GPU). Default: `false`.
    /// Requires the `layout-detection` feature.
    #[php(prop, name = "useLayoutForMarkdown")]
    pub use_layout_for_markdown: bool,
    /// Enable structured document tree output.
    ///
    /// When true, populates the `document` field on `ExtractionResult` with a
    /// hierarchical `DocumentStructure` containing heading-driven section nesting,
    /// table grids, content layer classification, and inline annotations.
    ///
    /// Independent of `result_format` — can be combined with Unified or ElementBased.
    #[php(prop, name = "includeDocumentStructure")]
    pub include_document_structure: bool,
    /// Hardware acceleration configuration for ONNX Runtime models.
    ///
    /// Controls execution provider selection for layout detection and embedding
    /// models. When `None`, uses platform defaults (CoreML on macOS, CUDA on
    /// Linux, CPU on Windows).
    pub acceleration: Option<AccelerationConfig>,
    /// Cache namespace for tenant isolation.
    ///
    /// When set, cache entries are stored under `{cache_dir}/{namespace}/`.
    /// Must be alphanumeric, hyphens, or underscores only (max 64 chars).
    /// Different namespaces have isolated cache spaces on the same filesystem.
    #[php(prop, name = "cacheNamespace")]
    pub cache_namespace: Option<String>,
    /// Per-request cache TTL in seconds.
    ///
    /// Overrides the global `max_age_days` for this specific extraction.
    /// When `0`, caching is completely skipped (no read or write).
    /// When `None`, the global TTL applies.
    #[php(prop, name = "cacheTtlSecs")]
    pub cache_ttl_secs: Option<i64>,
    /// Email extraction configuration (None = use defaults).
    ///
    /// Currently supports configuring the fallback codepage for MSG files
    /// that do not specify one. See `EmailConfig` for details.
    pub email: Option<EmailConfig>,
    /// Concurrency limits for constrained environments (None = use defaults).
    ///
    /// Controls Rayon thread pool size, ONNX Runtime intra-op threads, and
    /// (when `max_concurrent_extractions` is unset) the batch concurrency
    /// semaphore. See `ConcurrencyConfig` for details.
    #[php(prop, name = "concurrency")]
    #[serde(skip)]
    pub concurrency: Option<String>,
    /// Maximum recursion depth for archive extraction (default: 3).
    /// Set to 0 to disable recursive extraction (legacy behavior).
    #[php(prop, name = "maxArchiveDepth")]
    pub max_archive_depth: i64,
    /// Tree-sitter language pack configuration (None = tree-sitter disabled).
    ///
    /// When set, enables code file extraction using tree-sitter parsers.
    /// Controls grammar download behavior and code analysis options.
    pub tree_sitter: Option<TreeSitterConfig>,
    /// Structured extraction via LLM (None = disabled).
    ///
    /// When set, the extracted document content is sent to an LLM with the
    /// provided JSON schema. The structured response is stored in
    /// `ExtractionResult::structured_output`.
    pub structured_extraction: Option<StructuredExtractionConfig>,
    /// Cancellation token for this extraction (None = no external cancellation).
    ///
    /// Pass a [`CancellationToken`] clone here and call [`CancellationToken::cancel`]
    /// from another thread / task to abort the extraction in progress. The extractor
    /// checks the token at safe checkpoints (before lock acquisition, between pages,
    /// between batch items) and returns [`KreuzbergError::Cancelled`] when set.
    ///
    /// The field is excluded from serialization because `CancellationToken` is a
    /// runtime handle, not a configuration value.
    #[php(prop, name = "cancelToken")]
    #[serde(skip)]
    pub cancel_token: Option<String>,
}

impl Default for ExtractionConfig {
    fn default() -> Self {
        <kreuzberg::ExtractionConfig as Default>::default().into()
    }
}

#[php_impl]
impl ExtractionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        useCache: bool,
        enableQualityProcessing: bool,
        forceOcr: bool,
        forceOcrPages: Option<Vec<u32>>,
        disableOcr: bool,
        extractionTimeoutSecs: Option<i64>,
        maxConcurrentExtractions: Option<i64>,
        resultFormat: String,
        maxEmbeddedFileBytes: Option<i64>,
        outputFormat: String,
        useLayoutForMarkdown: bool,
        includeDocumentStructure: bool,
        cacheNamespace: Option<String>,
        cacheTtlSecs: Option<i64>,
        concurrency: Option<String>,
        maxArchiveDepth: i64,
        cancelToken: Option<String>,
    ) -> Self {
        Self {
            use_cache: useCache,
            enable_quality_processing: enableQualityProcessing,
            ocr: Default::default(),
            force_ocr: forceOcr,
            force_ocr_pages: forceOcrPages,
            disable_ocr: disableOcr,
            chunking: Default::default(),
            content_filter: Default::default(),
            images: Default::default(),
            pdf_options: Default::default(),
            token_reduction: Default::default(),
            language_detection: Default::default(),
            pages: Default::default(),
            keywords: Default::default(),
            postprocessor: Default::default(),
            html_options: Default::default(),
            html_output: Default::default(),
            extraction_timeout_secs: extractionTimeoutSecs,
            max_concurrent_extractions: maxConcurrentExtractions,
            result_format: resultFormat,
            security_limits: Default::default(),
            max_embedded_file_bytes: maxEmbeddedFileBytes,
            output_format: outputFormat,
            layout: Default::default(),
            use_layout_for_markdown: useLayoutForMarkdown,
            include_document_structure: includeDocumentStructure,
            acceleration: Default::default(),
            cache_namespace: cacheNamespace,
            cache_ttl_secs: cacheTtlSecs,
            email: Default::default(),
            concurrency,
            max_archive_depth: maxArchiveDepth,
            tree_sitter: Default::default(),
            structured_extraction: Default::default(),
            cancel_token: cancelToken,
        }
    }

    pub fn get_use_cache(&self) -> bool {
        self.use_cache.clone()
    }

    pub fn get_enable_quality_processing(&self) -> bool {
        self.enable_quality_processing.clone()
    }

    pub fn get_ocr(&self) -> Option<OcrConfig> {
        self.ocr.clone().map(Into::into)
    }

    pub fn get_force_ocr(&self) -> bool {
        self.force_ocr.clone()
    }

    pub fn get_force_ocr_pages(&self) -> Option<Vec<u32>> {
        self.force_ocr_pages.clone()
    }

    pub fn get_disable_ocr(&self) -> bool {
        self.disable_ocr.clone()
    }

    pub fn get_chunking(&self) -> Option<ChunkingConfig> {
        self.chunking.clone().map(Into::into)
    }

    pub fn get_content_filter(&self) -> Option<ContentFilterConfig> {
        self.content_filter.clone().map(Into::into)
    }

    pub fn get_images(&self) -> Option<ImageExtractionConfig> {
        self.images.clone().map(Into::into)
    }

    pub fn get_pdf_options(&self) -> Option<PdfConfig> {
        self.pdf_options.clone().map(Into::into)
    }

    pub fn get_token_reduction(&self) -> Option<TokenReductionOptions> {
        self.token_reduction.clone().map(Into::into)
    }

    pub fn get_language_detection(&self) -> Option<LanguageDetectionConfig> {
        self.language_detection.clone().map(Into::into)
    }

    pub fn get_pages(&self) -> Option<PageConfig> {
        self.pages.clone().map(Into::into)
    }

    pub fn get_keywords(&self) -> Option<KeywordConfig> {
        self.keywords.clone().map(Into::into)
    }

    pub fn get_postprocessor(&self) -> Option<PostProcessorConfig> {
        self.postprocessor.clone().map(Into::into)
    }

    pub fn get_html_options(&self) -> Option<String> {
        self.html_options.clone()
    }

    pub fn get_html_output(&self) -> Option<HtmlOutputConfig> {
        self.html_output.clone().map(Into::into)
    }

    pub fn get_extraction_timeout_secs(&self) -> Option<i64> {
        self.extraction_timeout_secs.clone()
    }

    pub fn get_max_concurrent_extractions(&self) -> Option<i64> {
        self.max_concurrent_extractions.clone()
    }

    pub fn get_result_format(&self) -> String {
        self.result_format.clone()
    }

    pub fn get_security_limits(&self) -> Option<SecurityLimits> {
        self.security_limits.clone().map(Into::into)
    }

    pub fn get_max_embedded_file_bytes(&self) -> Option<i64> {
        self.max_embedded_file_bytes.clone()
    }

    pub fn get_output_format(&self) -> String {
        self.output_format.clone()
    }

    pub fn get_layout(&self) -> Option<LayoutDetectionConfig> {
        self.layout.clone().map(Into::into)
    }

    pub fn get_use_layout_for_markdown(&self) -> bool {
        self.use_layout_for_markdown.clone()
    }

    pub fn get_include_document_structure(&self) -> bool {
        self.include_document_structure.clone()
    }

    pub fn get_acceleration(&self) -> Option<AccelerationConfig> {
        self.acceleration.clone().map(Into::into)
    }

    pub fn get_cache_namespace(&self) -> Option<String> {
        self.cache_namespace.clone()
    }

    pub fn get_cache_ttl_secs(&self) -> Option<i64> {
        self.cache_ttl_secs.clone()
    }

    pub fn get_email(&self) -> Option<EmailConfig> {
        self.email.clone().map(Into::into)
    }

    pub fn get_concurrency(&self) -> Option<String> {
        self.concurrency.clone()
    }

    pub fn get_max_archive_depth(&self) -> i64 {
        self.max_archive_depth.clone()
    }

    pub fn get_tree_sitter(&self) -> Option<TreeSitterConfig> {
        self.tree_sitter.clone().map(Into::into)
    }

    pub fn get_structured_extraction(&self) -> Option<StructuredExtractionConfig> {
        self.structured_extraction.clone().map(Into::into)
    }

    pub fn get_cancel_token(&self) -> Option<String> {
        self.cancel_token.clone()
    }

    pub fn needs_image_processing(&self) -> bool {
        #[allow(clippy::needless_update)]
        let core_self = kreuzberg::ExtractionConfig {
            use_cache: self.use_cache,
            enable_quality_processing: self.enable_quality_processing,
            ocr: self.ocr.clone().map(Into::into),
            force_ocr: self.force_ocr,
            force_ocr_pages: self.force_ocr_pages.clone(),
            disable_ocr: self.disable_ocr,
            chunking: self.chunking.clone().map(Into::into),
            content_filter: self.content_filter.clone().map(Into::into),
            images: self.images.clone().map(Into::into),
            token_reduction: self.token_reduction.clone().map(Into::into),
            language_detection: self.language_detection.clone().map(Into::into),
            pages: self.pages.clone().map(Into::into),
            postprocessor: self.postprocessor.clone().map(Into::into),
            extraction_timeout_secs: self.extraction_timeout_secs.map(|v| v as u64),
            max_concurrent_extractions: self.max_concurrent_extractions.map(|v| v as usize),
            result_format: match self.result_format.as_str() {
                "unified" => kreuzberg::ResultFormat::Unified,
                "element_based" => kreuzberg::ResultFormat::ElementBased,
                _ => Default::default(),
            },
            security_limits: self.security_limits.clone().map(Into::into),
            max_embedded_file_bytes: self.max_embedded_file_bytes.map(|v| v as u64),
            output_format: match self.output_format.as_str() {
                "plain" => kreuzberg::OutputFormat::Plain,
                "markdown" => kreuzberg::OutputFormat::Markdown,
                "djot" => kreuzberg::OutputFormat::Djot,
                "html" => kreuzberg::OutputFormat::Html,
                "json" => kreuzberg::OutputFormat::Json,
                "structured" => kreuzberg::OutputFormat::Structured,
                "custom" => kreuzberg::OutputFormat::Custom(Default::default()),
                _ => Default::default(),
            },
            use_layout_for_markdown: self.use_layout_for_markdown,
            include_document_structure: self.include_document_structure,
            acceleration: self.acceleration.clone().map(Into::into),
            cache_namespace: self.cache_namespace.clone(),
            cache_ttl_secs: self.cache_ttl_secs.map(|v| v as u64),
            email: self.email.clone().map(Into::into),
            concurrency: Default::default(),
            max_archive_depth: self.max_archive_depth as usize,
            structured_extraction: self.structured_extraction.clone().map(Into::into),
            cancel_token: Default::default(),
            ..Default::default()
        };
        core_self.needs_image_processing()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> ExtractionConfig {
        kreuzberg::ExtractionConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\FileExtractionConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct FileExtractionConfig {
    /// Override quality post-processing for this file.
    #[php(prop, name = "enableQualityProcessing")]
    pub enable_quality_processing: Option<bool>,
    /// Override OCR configuration for this file (None in the Option = use batch default).
    pub ocr: Option<OcrConfig>,
    /// Override force OCR for this file.
    #[php(prop, name = "forceOcr")]
    pub force_ocr: Option<bool>,
    /// Override force OCR pages for this file (1-indexed page numbers).
    #[php(prop, name = "forceOcrPages")]
    pub force_ocr_pages: Option<Vec<u32>>,
    /// Override disable OCR for this file.
    #[php(prop, name = "disableOcr")]
    pub disable_ocr: Option<bool>,
    /// Override chunking configuration for this file.
    pub chunking: Option<ChunkingConfig>,
    /// Override content filtering configuration for this file.
    pub content_filter: Option<ContentFilterConfig>,
    /// Override image extraction configuration for this file.
    pub images: Option<ImageExtractionConfig>,
    /// Override PDF options for this file.
    pub pdf_options: Option<PdfConfig>,
    /// Override token reduction for this file.
    pub token_reduction: Option<TokenReductionOptions>,
    /// Override language detection for this file.
    pub language_detection: Option<LanguageDetectionConfig>,
    /// Override page extraction for this file.
    pub pages: Option<PageConfig>,
    /// Override keyword extraction for this file.
    pub keywords: Option<KeywordConfig>,
    /// Override post-processor for this file.
    pub postprocessor: Option<PostProcessorConfig>,
    /// Override HTML conversion options for this file.
    #[php(prop, name = "htmlOptions")]
    #[serde(skip)]
    pub html_options: Option<String>,
    /// Override result format for this file.
    #[php(prop, name = "resultFormat")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub result_format: Option<String>,
    /// Override output content format for this file.
    #[php(prop, name = "outputFormat")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output_format: Option<String>,
    /// Override document structure output for this file.
    #[php(prop, name = "includeDocumentStructure")]
    pub include_document_structure: Option<bool>,
    /// Override layout detection for this file.
    pub layout: Option<LayoutDetectionConfig>,
    /// Override per-file extraction timeout in seconds.
    ///
    /// When set, the extraction for this file will be canceled after the
    /// specified duration. A timed-out file produces an error result without
    /// affecting other files in the batch.
    #[php(prop, name = "timeoutSecs")]
    pub timeout_secs: Option<i64>,
    /// Override tree-sitter configuration for this file.
    pub tree_sitter: Option<TreeSitterConfig>,
    /// Override structured extraction configuration for this file.
    ///
    /// When set, enables LLM-based structured extraction with a JSON schema
    /// for this specific file. The extracted content is sent to a VLM/LLM
    /// and the response is parsed according to the provided schema.
    pub structured_extraction: Option<StructuredExtractionConfig>,
}

impl Default for FileExtractionConfig {
    fn default() -> Self {
        <kreuzberg::FileExtractionConfig as Default>::default().into()
    }
}

#[php_impl]
impl FileExtractionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_enable_quality_processing(&self) -> Option<bool> {
        self.enable_quality_processing.clone()
    }

    pub fn get_ocr(&self) -> Option<OcrConfig> {
        self.ocr.clone().map(Into::into)
    }

    pub fn get_force_ocr(&self) -> Option<bool> {
        self.force_ocr.clone()
    }

    pub fn get_force_ocr_pages(&self) -> Option<Vec<u32>> {
        self.force_ocr_pages.clone()
    }

    pub fn get_disable_ocr(&self) -> Option<bool> {
        self.disable_ocr.clone()
    }

    pub fn get_chunking(&self) -> Option<ChunkingConfig> {
        self.chunking.clone().map(Into::into)
    }

    pub fn get_content_filter(&self) -> Option<ContentFilterConfig> {
        self.content_filter.clone().map(Into::into)
    }

    pub fn get_images(&self) -> Option<ImageExtractionConfig> {
        self.images.clone().map(Into::into)
    }

    pub fn get_pdf_options(&self) -> Option<PdfConfig> {
        self.pdf_options.clone().map(Into::into)
    }

    pub fn get_token_reduction(&self) -> Option<TokenReductionOptions> {
        self.token_reduction.clone().map(Into::into)
    }

    pub fn get_language_detection(&self) -> Option<LanguageDetectionConfig> {
        self.language_detection.clone().map(Into::into)
    }

    pub fn get_pages(&self) -> Option<PageConfig> {
        self.pages.clone().map(Into::into)
    }

    pub fn get_keywords(&self) -> Option<KeywordConfig> {
        self.keywords.clone().map(Into::into)
    }

    pub fn get_postprocessor(&self) -> Option<PostProcessorConfig> {
        self.postprocessor.clone().map(Into::into)
    }

    pub fn get_html_options(&self) -> Option<String> {
        self.html_options.clone()
    }

    pub fn get_result_format(&self) -> Option<String> {
        self.result_format.clone().map(Into::into)
    }

    pub fn get_output_format(&self) -> Option<String> {
        self.output_format.clone().map(Into::into)
    }

    pub fn get_include_document_structure(&self) -> Option<bool> {
        self.include_document_structure.clone()
    }

    pub fn get_layout(&self) -> Option<LayoutDetectionConfig> {
        self.layout.clone().map(Into::into)
    }

    pub fn get_timeout_secs(&self) -> Option<i64> {
        self.timeout_secs.clone()
    }

    pub fn get_tree_sitter(&self) -> Option<TreeSitterConfig> {
        self.tree_sitter.clone().map(Into::into)
    }

    pub fn get_structured_extraction(&self) -> Option<StructuredExtractionConfig> {
        self.structured_extraction.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\BatchBytesItem")]
#[serde(default, rename_all = "camelCase")]
pub struct BatchBytesItem {
    /// The content bytes to extract from
    pub content: Vec<u8>,
    /// MIME type of the content (e.g., "application/pdf", "text/html")
    #[php(prop, name = "mimeType")]
    pub mime_type: String,
    /// Per-item configuration overrides (None uses batch-level defaults)
    pub config: Option<FileExtractionConfig>,
}

#[php_impl]
impl BatchBytesItem {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: PhpBytes, mimeType: String) -> Self {
        Self {
            content: content.0,
            mime_type: mimeType,
            config: Default::default(),
        }
    }

    pub fn get_content(&self) -> Vec<u8> {
        self.content.clone()
    }

    pub fn get_mime_type(&self) -> String {
        self.mime_type.clone()
    }

    pub fn get_config(&self) -> Option<FileExtractionConfig> {
        self.config.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\BatchFileItem")]
#[serde(default, rename_all = "camelCase")]
pub struct BatchFileItem {
    /// Path to the file to extract from
    #[php(prop, name = "path")]
    pub path: String,
    /// Per-file configuration overrides (None uses batch-level defaults)
    pub config: Option<FileExtractionConfig>,
}

#[php_impl]
impl BatchFileItem {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(path: String) -> Self {
        Self {
            path,
            config: Default::default(),
        }
    }

    pub fn get_path(&self) -> String {
        self.path.clone()
    }

    pub fn get_config(&self) -> Option<FileExtractionConfig> {
        self.config.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ImageExtractionConfig")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct ImageExtractionConfig {
    /// Extract images from documents
    #[php(prop, name = "extractImages")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub extract_images: bool,
    /// Target DPI for image normalization
    #[php(prop, name = "targetDpi")]
    pub target_dpi: i32,
    /// Maximum dimension for images (width or height)
    #[php(prop, name = "maxImageDimension")]
    pub max_image_dimension: i32,
    /// Whether to inject image reference placeholders into markdown output.
    /// When `true` (default), image references like `![Image 1](embedded:p1_i0)`
    /// are appended to the markdown. Set to `false` to extract images as data
    /// without polluting the markdown output.
    #[php(prop, name = "injectPlaceholders")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub inject_placeholders: bool,
    /// Automatically adjust DPI based on image content
    #[php(prop, name = "autoAdjustDpi")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub auto_adjust_dpi: bool,
    /// Minimum DPI threshold
    #[php(prop, name = "minDpi")]
    pub min_dpi: i32,
    /// Maximum DPI threshold
    #[php(prop, name = "maxDpi")]
    pub max_dpi: i32,
    /// Maximum number of image objects to extract per PDF page.
    ///
    /// Some PDFs (e.g. technical diagrams stored as thousands of raster fragments)
    /// can trigger extremely long or indefinite extraction times when every image
    /// object on a dense page is decoded individually via the PDF extractor. Setting this
    /// limit causes kreuzberg to stop collecting individual images once the count
    /// per page reaches the cap and emit a warning instead.
    ///
    /// `None` (default) means no limit — all images are extracted.
    #[php(prop, name = "maxImagesPerPage")]
    pub max_images_per_page: Option<u32>,
    /// When `true` (default), extracted images are classified by kind and grouped
    /// into clusters where they appear to belong to one figure.
    #[php(prop, name = "classify")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub classify: bool,
    /// When `true`, full-page renders produced during OCR preprocessing are captured
    /// and returned as `ImageKind::PageRaster` entries in `ExtractionResult.images`.
    ///
    /// **PDF + OCR only.** No rasters are captured for non-PDF inputs or when the
    /// document-level OCR bypass is active (whole-document backend). When OCR is
    /// enabled and this flag is set but the active backend skips per-page rendering,
    /// a `ProcessingWarning` is emitted in `ExtractionResult.processing_warnings`.
    ///
    /// Defaults to `false`. Enable when downstream consumers need page thumbnails
    /// (e.g. citation previews, visual grounding).
    #[php(prop, name = "includePageRasters")]
    pub include_page_rasters: bool,
    /// Run OCR on extracted images and include the recognized text in the document content.
    ///
    /// When `true` (default) and `ExtractionConfig.ocr` is configured, extracted images
    /// are processed with the configured OCR backend. Set to `false` to extract images
    /// without OCR processing, even when OCR is enabled.
    #[php(prop, name = "runOcrOnImages")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub run_ocr_on_images: bool,
    /// When `true`, image OCR results are rendered as plain text without the
    /// `![...](...)` markdown placeholder. Only takes effect when `run_ocr_on_images`
    /// is also `true`.
    #[php(prop, name = "ocrTextOnly")]
    pub ocr_text_only: bool,
    /// When `true` and `ocr_text_only` is `false`, append the OCR text after
    /// the image placeholder in the rendered output.
    #[php(prop, name = "appendOcrText")]
    pub append_ocr_text: bool,
}

impl Default for ImageExtractionConfig {
    fn default() -> Self {
        <kreuzberg::ImageExtractionConfig as Default>::default().into()
    }
}

#[php_impl]
impl ImageExtractionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        extractImages: bool,
        targetDpi: i32,
        maxImageDimension: i32,
        injectPlaceholders: bool,
        autoAdjustDpi: bool,
        minDpi: i32,
        maxDpi: i32,
        maxImagesPerPage: Option<u32>,
        classify: bool,
        includePageRasters: bool,
        runOcrOnImages: bool,
        ocrTextOnly: bool,
        appendOcrText: bool,
    ) -> Self {
        Self {
            extract_images: extractImages,
            target_dpi: targetDpi,
            max_image_dimension: maxImageDimension,
            inject_placeholders: injectPlaceholders,
            auto_adjust_dpi: autoAdjustDpi,
            min_dpi: minDpi,
            max_dpi: maxDpi,
            max_images_per_page: maxImagesPerPage,
            classify,
            include_page_rasters: includePageRasters,
            run_ocr_on_images: runOcrOnImages,
            ocr_text_only: ocrTextOnly,
            append_ocr_text: appendOcrText,
        }
    }

    pub fn get_extract_images(&self) -> bool {
        self.extract_images.clone()
    }

    pub fn get_target_dpi(&self) -> i32 {
        self.target_dpi.clone()
    }

    pub fn get_max_image_dimension(&self) -> i32 {
        self.max_image_dimension.clone()
    }

    pub fn get_inject_placeholders(&self) -> bool {
        self.inject_placeholders.clone()
    }

    pub fn get_auto_adjust_dpi(&self) -> bool {
        self.auto_adjust_dpi.clone()
    }

    pub fn get_min_dpi(&self) -> i32 {
        self.min_dpi.clone()
    }

    pub fn get_max_dpi(&self) -> i32 {
        self.max_dpi.clone()
    }

    pub fn get_max_images_per_page(&self) -> Option<u32> {
        self.max_images_per_page.clone()
    }

    pub fn get_classify(&self) -> bool {
        self.classify.clone()
    }

    pub fn get_include_page_rasters(&self) -> bool {
        self.include_page_rasters.clone()
    }

    pub fn get_run_ocr_on_images(&self) -> bool {
        self.run_ocr_on_images.clone()
    }

    pub fn get_ocr_text_only(&self) -> bool {
        self.ocr_text_only.clone()
    }

    pub fn get_append_ocr_text(&self) -> bool {
        self.append_ocr_text.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> ImageExtractionConfig {
        kreuzberg::ImageExtractionConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TokenReductionOptions")]
#[serde(default, rename_all = "camelCase")]
pub struct TokenReductionOptions {
    /// Reduction mode: "off", "light", "moderate", "aggressive", "maximum"
    #[php(prop, name = "mode")]
    pub mode: String,
    /// Preserve important words (capitalized, technical terms)
    #[php(prop, name = "preserveImportantWords")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub preserve_important_words: bool,
}

impl Default for TokenReductionOptions {
    fn default() -> Self {
        <kreuzberg::TokenReductionOptions as Default>::default().into()
    }
}

#[php_impl]
impl TokenReductionOptions {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(mode: String, preserveImportantWords: bool) -> Self {
        Self {
            mode,
            preserve_important_words: preserveImportantWords,
        }
    }

    pub fn get_mode(&self) -> String {
        self.mode.clone()
    }

    pub fn get_preserve_important_words(&self) -> bool {
        self.preserve_important_words.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> TokenReductionOptions {
        kreuzberg::TokenReductionOptions::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\LanguageDetectionConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct LanguageDetectionConfig {
    /// Enable language detection
    #[php(prop, name = "enabled")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enabled: bool,
    /// Minimum confidence threshold (0.0-1.0)
    #[php(prop, name = "minConfidence")]
    pub min_confidence: f64,
    /// Detect multiple languages in the document
    #[php(prop, name = "detectMultiple")]
    pub detect_multiple: bool,
}

impl Default for LanguageDetectionConfig {
    fn default() -> Self {
        <kreuzberg::LanguageDetectionConfig as Default>::default().into()
    }
}

#[php_impl]
impl LanguageDetectionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(enabled: bool, minConfidence: f64, detectMultiple: bool) -> Self {
        Self {
            enabled,
            min_confidence: minConfidence,
            detect_multiple: detectMultiple,
        }
    }

    pub fn get_enabled(&self) -> bool {
        self.enabled.clone()
    }

    pub fn get_min_confidence(&self) -> f64 {
        self.min_confidence.clone()
    }

    pub fn get_detect_multiple(&self) -> bool {
        self.detect_multiple.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> LanguageDetectionConfig {
        kreuzberg::LanguageDetectionConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\HtmlOutputConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct HtmlOutputConfig {
    /// Inline CSS string injected into the output after the theme stylesheet.
    /// Concatenated after `css_file` content when both are set.
    #[php(prop, name = "css")]
    pub css: Option<String>,
    /// Path to a CSS file loaded once at renderer construction time.
    /// Concatenated before `css` when both are set.
    #[php(prop, name = "cssFile")]
    pub css_file: Option<String>,
    /// Built-in colour/typography theme. Default: [`HtmlTheme::Unstyled`].
    #[php(prop, name = "theme")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub theme: String,
    /// CSS class prefix applied to every emitted class name.
    ///
    /// Default: `"kb-"`. Change this if your host application already uses
    /// classes that start with `kb-`.
    #[php(prop, name = "classPrefix")]
    pub class_prefix: String,
    /// When `true` (default), write the resolved CSS into a `<style>` block
    /// immediately after the opening `<div class="{prefix}doc">`.
    ///
    /// Set to `false` to emit only the structural markup and wire up your
    /// own stylesheet targeting the `kb-*` class names.
    #[php(prop, name = "embedCss")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub embed_css: bool,
}

impl Default for HtmlOutputConfig {
    fn default() -> Self {
        <kreuzberg::HtmlOutputConfig as Default>::default().into()
    }
}

#[php_impl]
impl HtmlOutputConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        css: Option<String>,
        cssFile: Option<String>,
        theme: String,
        classPrefix: String,
        embedCss: bool,
    ) -> Self {
        Self {
            css,
            css_file: cssFile,
            theme,
            class_prefix: classPrefix,
            embed_css: embedCss,
        }
    }

    pub fn get_css(&self) -> Option<String> {
        self.css.clone()
    }

    pub fn get_css_file(&self) -> Option<String> {
        self.css_file.clone()
    }

    pub fn get_theme(&self) -> String {
        self.theme.clone()
    }

    pub fn get_class_prefix(&self) -> String {
        self.class_prefix.clone()
    }

    pub fn get_embed_css(&self) -> bool {
        self.embed_css.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> HtmlOutputConfig {
        kreuzberg::HtmlOutputConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\LayoutDetectionConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct LayoutDetectionConfig {
    /// Confidence threshold override (None = use model default).
    #[php(prop, name = "confidenceThreshold")]
    pub confidence_threshold: Option<f32>,
    /// Whether to apply postprocessing heuristics (default: true).
    #[php(prop, name = "applyHeuristics")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub apply_heuristics: bool,
    /// Table structure recognition model.
    ///
    /// Controls which model is used for table cell detection within layout-detected
    /// table regions. Defaults to [`TableModel::Tatr`].
    #[php(prop, name = "tableModel")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub table_model: String,
    /// Hardware acceleration for ONNX models (layout detection + table structure).
    ///
    /// When set, controls which execution provider (CPU, CUDA, CoreML, TensorRT)
    /// is used for inference. Defaults to `None` (auto-select per platform).
    pub acceleration: Option<AccelerationConfig>,
}

impl Default for LayoutDetectionConfig {
    fn default() -> Self {
        <kreuzberg::LayoutDetectionConfig as Default>::default().into()
    }
}

#[php_impl]
impl LayoutDetectionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(confidenceThreshold: Option<f32>, applyHeuristics: bool, tableModel: String) -> Self {
        Self {
            confidence_threshold: confidenceThreshold,
            apply_heuristics: applyHeuristics,
            table_model: tableModel,
            acceleration: Default::default(),
        }
    }

    pub fn get_confidence_threshold(&self) -> Option<f32> {
        self.confidence_threshold.clone()
    }

    pub fn get_apply_heuristics(&self) -> bool {
        self.apply_heuristics.clone()
    }

    pub fn get_table_model(&self) -> String {
        self.table_model.clone()
    }

    pub fn get_acceleration(&self) -> Option<AccelerationConfig> {
        self.acceleration.clone().map(Into::into)
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> LayoutDetectionConfig {
        kreuzberg::LayoutDetectionConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\LlmConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct LlmConfig {
    /// Provider/model string using liter-llm routing format.
    ///
    /// Examples: `"openai/gpt-4o"`, `"anthropic/claude-sonnet-4-20250514"`,
    /// `"groq/llama-3.1-70b-versatile"`.
    #[php(prop, name = "model")]
    pub model: String,
    /// API key for the provider. When `None`, liter-llm falls back to
    /// the provider's standard environment variable (e.g., `OPENAI_API_KEY`).
    #[php(prop, name = "apiKey")]
    pub api_key: Option<String>,
    /// Custom base URL override for the provider endpoint.
    #[php(prop, name = "baseUrl")]
    pub base_url: Option<String>,
    /// Request timeout in seconds (default: 60).
    #[php(prop, name = "timeoutSecs")]
    pub timeout_secs: Option<i64>,
    /// Maximum retry attempts (default: 3).
    #[php(prop, name = "maxRetries")]
    pub max_retries: Option<u32>,
    /// Sampling temperature for generation tasks.
    #[php(prop, name = "temperature")]
    pub temperature: Option<f64>,
    /// Maximum tokens to generate.
    #[php(prop, name = "maxTokens")]
    pub max_tokens: Option<i64>,
}

impl Default for LlmConfig {
    fn default() -> Self {
        <kreuzberg::LlmConfig as Default>::default().into()
    }
}

#[php_impl]
impl LlmConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        model: String,
        apiKey: Option<String>,
        baseUrl: Option<String>,
        timeoutSecs: Option<i64>,
        maxRetries: Option<u32>,
        temperature: Option<f64>,
        maxTokens: Option<i64>,
    ) -> Self {
        Self {
            model,
            api_key: apiKey,
            base_url: baseUrl,
            timeout_secs: timeoutSecs,
            max_retries: maxRetries,
            temperature,
            max_tokens: maxTokens,
        }
    }

    pub fn get_model(&self) -> String {
        self.model.clone()
    }

    pub fn get_api_key(&self) -> Option<String> {
        self.api_key.clone()
    }

    pub fn get_base_url(&self) -> Option<String> {
        self.base_url.clone()
    }

    pub fn get_timeout_secs(&self) -> Option<i64> {
        self.timeout_secs.clone()
    }

    pub fn get_max_retries(&self) -> Option<u32> {
        self.max_retries.clone()
    }

    pub fn get_temperature(&self) -> Option<f64> {
        self.temperature.clone()
    }

    pub fn get_max_tokens(&self) -> Option<i64> {
        self.max_tokens.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\StructuredExtractionConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct StructuredExtractionConfig {
    /// JSON Schema defining the desired output structure.
    pub schema: serde_json::Value,
    /// Schema name passed to the LLM's structured output mode.
    #[php(prop, name = "schemaName")]
    pub schema_name: String,
    /// Optional schema description for the LLM.
    #[php(prop, name = "schemaDescription")]
    pub schema_description: Option<String>,
    /// Enable strict mode — output must exactly match the schema.
    #[php(prop, name = "strict")]
    pub strict: bool,
    /// Custom Jinja2 extraction prompt template. When `None`, a default template is used.
    ///
    /// Available template variables:
    /// - `{{ content }}` — The extracted document text.
    /// - `{{ schema }}` — The JSON schema as a formatted string.
    /// - `{{ schema_name }}` — The schema name.
    /// - `{{ schema_description }}` — The schema description (may be empty).
    #[php(prop, name = "prompt")]
    pub prompt: Option<String>,
    /// LLM configuration for the extraction.
    pub llm: LlmConfig,
}

#[php_impl]
impl StructuredExtractionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(schemaName: String, schemaDescription: Option<String>, strict: bool, prompt: Option<String>) -> Self {
        Self {
            schema: Default::default(),
            schema_name: schemaName,
            schema_description: schemaDescription,
            strict,
            prompt,
            llm: Default::default(),
        }
    }

    pub fn get_schema(&self) -> Option<String> {
        serde_json::to_string(&self.schema).ok()
    }

    pub fn get_schema_name(&self) -> String {
        self.schema_name.clone()
    }

    pub fn get_schema_description(&self) -> Option<String> {
        self.schema_description.clone()
    }

    pub fn get_strict(&self) -> bool {
        self.strict.clone()
    }

    pub fn get_prompt(&self) -> Option<String> {
        self.prompt.clone()
    }

    pub fn get_llm(&self) -> LlmConfig {
        self.llm.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\OcrQualityThresholds")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrQualityThresholds {
    /// Minimum total non-whitespace characters to consider text substantive.
    #[php(prop, name = "minTotalNonWhitespace")]
    pub min_total_non_whitespace: i64,
    /// Minimum non-whitespace characters per page on average.
    #[php(prop, name = "minNonWhitespacePerPage")]
    pub min_non_whitespace_per_page: f64,
    /// Minimum character count for a word to be "meaningful".
    #[php(prop, name = "minMeaningfulWordLen")]
    pub min_meaningful_word_len: i64,
    /// Minimum count of meaningful words before text is accepted.
    #[php(prop, name = "minMeaningfulWords")]
    pub min_meaningful_words: i64,
    /// Minimum alphanumeric ratio (non-whitespace chars that are alphanumeric).
    #[php(prop, name = "minAlnumRatio")]
    pub min_alnum_ratio: f64,
    /// Minimum Unicode replacement characters (U+FFFD) to trigger OCR fallback.
    #[php(prop, name = "minGarbageChars")]
    pub min_garbage_chars: i64,
    /// Maximum fraction of short (1-2 char) words before text is considered fragmented.
    #[php(prop, name = "maxFragmentedWordRatio")]
    pub max_fragmented_word_ratio: f64,
    /// Critical fragmentation threshold — triggers OCR regardless of meaningful words.
    /// Normal English text has ~20-30% short words. 80%+ is definitive garbage.
    #[php(prop, name = "criticalFragmentedWordRatio")]
    pub critical_fragmented_word_ratio: f64,
    /// Minimum average word length. Below this with enough words indicates garbled extraction.
    #[php(prop, name = "minAvgWordLength")]
    pub min_avg_word_length: f64,
    /// Minimum word count before average word length check applies.
    #[php(prop, name = "minWordsForAvgLengthCheck")]
    pub min_words_for_avg_length_check: i64,
    /// Minimum consecutive word repetition ratio to detect column scrambling.
    #[php(prop, name = "minConsecutiveRepeatRatio")]
    pub min_consecutive_repeat_ratio: f64,
    /// Minimum word count before consecutive repetition check is applied.
    #[php(prop, name = "minWordsForRepeatCheck")]
    pub min_words_for_repeat_check: i64,
    /// Minimum character count for "substantive markdown" OCR skip gate.
    #[php(prop, name = "substantiveMinChars")]
    pub substantive_min_chars: i64,
    /// Minimum character count for "non-text content" OCR skip gate.
    #[php(prop, name = "nonTextMinChars")]
    pub non_text_min_chars: i64,
    /// Alphanumeric+whitespace ratio threshold for skip decisions.
    #[php(prop, name = "alnumWsRatioThreshold")]
    pub alnum_ws_ratio_threshold: f64,
    /// Minimum quality score (0.0-1.0) for a pipeline stage result to be accepted.
    /// If the result from a backend scores below this, try the next backend.
    #[php(prop, name = "pipelineMinQuality")]
    pub pipeline_min_quality: f64,
}

impl Default for OcrQualityThresholds {
    fn default() -> Self {
        <kreuzberg::OcrQualityThresholds as Default>::default().into()
    }
}

#[php_impl]
impl OcrQualityThresholds {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        minTotalNonWhitespace: i64,
        minNonWhitespacePerPage: f64,
        minMeaningfulWordLen: i64,
        minMeaningfulWords: i64,
        minAlnumRatio: f64,
        minGarbageChars: i64,
        maxFragmentedWordRatio: f64,
        criticalFragmentedWordRatio: f64,
        minAvgWordLength: f64,
        minWordsForAvgLengthCheck: i64,
        minConsecutiveRepeatRatio: f64,
        minWordsForRepeatCheck: i64,
        substantiveMinChars: i64,
        nonTextMinChars: i64,
        alnumWsRatioThreshold: f64,
        pipelineMinQuality: f64,
    ) -> Self {
        Self {
            min_total_non_whitespace: minTotalNonWhitespace,
            min_non_whitespace_per_page: minNonWhitespacePerPage,
            min_meaningful_word_len: minMeaningfulWordLen,
            min_meaningful_words: minMeaningfulWords,
            min_alnum_ratio: minAlnumRatio,
            min_garbage_chars: minGarbageChars,
            max_fragmented_word_ratio: maxFragmentedWordRatio,
            critical_fragmented_word_ratio: criticalFragmentedWordRatio,
            min_avg_word_length: minAvgWordLength,
            min_words_for_avg_length_check: minWordsForAvgLengthCheck,
            min_consecutive_repeat_ratio: minConsecutiveRepeatRatio,
            min_words_for_repeat_check: minWordsForRepeatCheck,
            substantive_min_chars: substantiveMinChars,
            non_text_min_chars: nonTextMinChars,
            alnum_ws_ratio_threshold: alnumWsRatioThreshold,
            pipeline_min_quality: pipelineMinQuality,
        }
    }

    pub fn get_min_total_non_whitespace(&self) -> i64 {
        self.min_total_non_whitespace.clone()
    }

    pub fn get_min_non_whitespace_per_page(&self) -> f64 {
        self.min_non_whitespace_per_page.clone()
    }

    pub fn get_min_meaningful_word_len(&self) -> i64 {
        self.min_meaningful_word_len.clone()
    }

    pub fn get_min_meaningful_words(&self) -> i64 {
        self.min_meaningful_words.clone()
    }

    pub fn get_min_alnum_ratio(&self) -> f64 {
        self.min_alnum_ratio.clone()
    }

    pub fn get_min_garbage_chars(&self) -> i64 {
        self.min_garbage_chars.clone()
    }

    pub fn get_max_fragmented_word_ratio(&self) -> f64 {
        self.max_fragmented_word_ratio.clone()
    }

    pub fn get_critical_fragmented_word_ratio(&self) -> f64 {
        self.critical_fragmented_word_ratio.clone()
    }

    pub fn get_min_avg_word_length(&self) -> f64 {
        self.min_avg_word_length.clone()
    }

    pub fn get_min_words_for_avg_length_check(&self) -> i64 {
        self.min_words_for_avg_length_check.clone()
    }

    pub fn get_min_consecutive_repeat_ratio(&self) -> f64 {
        self.min_consecutive_repeat_ratio.clone()
    }

    pub fn get_min_words_for_repeat_check(&self) -> i64 {
        self.min_words_for_repeat_check.clone()
    }

    pub fn get_substantive_min_chars(&self) -> i64 {
        self.substantive_min_chars.clone()
    }

    pub fn get_non_text_min_chars(&self) -> i64 {
        self.non_text_min_chars.clone()
    }

    pub fn get_alnum_ws_ratio_threshold(&self) -> f64 {
        self.alnum_ws_ratio_threshold.clone()
    }

    pub fn get_pipeline_min_quality(&self) -> f64 {
        self.pipeline_min_quality.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> OcrQualityThresholds {
        kreuzberg::OcrQualityThresholds::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OcrPipelineStage")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrPipelineStage {
    /// Backend name: "tesseract", "paddleocr", "easyocr", or a custom registered name.
    #[php(prop, name = "backend")]
    pub backend: String,
    /// Priority weight (higher = tried first). Stages are sorted by priority descending.
    #[php(prop, name = "priority")]
    pub priority: u32,
    /// Language override for this stage (None = use parent OcrConfig.language).
    #[php(prop, name = "language")]
    pub language: Option<String>,
    /// Tesseract-specific config override for this stage.
    pub tesseract_config: Option<TesseractConfig>,
    /// PaddleOCR-specific config for this stage.
    pub paddle_ocr_config: Option<serde_json::Value>,
    /// VLM config override for this pipeline stage.
    pub vlm_config: Option<LlmConfig>,
    /// Arbitrary per-call options passed through to the backend unchanged.
    ///
    /// Backends that support runtime tuning (mode switching, preprocessing
    /// flags, inference parameters, etc.) read this value and deserialize
    /// the keys they care about. Keys unknown to the backend are silently
    /// ignored, so options from different backends can coexist in the same
    /// config without conflict.
    ///
    /// Example (custom backend):
    /// ```json
    /// { "mode": "fast", "enable_layout": true }
    /// ```
    pub backend_options: Option<serde_json::Value>,
}

#[php_impl]
impl OcrPipelineStage {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(backend: String, priority: u32, language: Option<String>) -> Self {
        Self {
            backend,
            priority,
            language,
            tesseract_config: Default::default(),
            paddle_ocr_config: Default::default(),
            vlm_config: Default::default(),
            backend_options: Default::default(),
        }
    }

    pub fn get_backend(&self) -> String {
        self.backend.clone()
    }

    pub fn get_priority(&self) -> u32 {
        self.priority.clone()
    }

    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    pub fn get_tesseract_config(&self) -> Option<TesseractConfig> {
        self.tesseract_config.clone().map(Into::into)
    }

    pub fn get_paddle_ocr_config(&self) -> Option<String> {
        self.paddle_ocr_config
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }

    pub fn get_vlm_config(&self) -> Option<LlmConfig> {
        self.vlm_config.clone().map(Into::into)
    }

    pub fn get_backend_options(&self) -> Option<String> {
        self.backend_options
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OcrPipelineConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrPipelineConfig {
    /// Ordered list of backends to try. Sorted by priority (descending) at runtime.
    pub stages: Vec<OcrPipelineStage>,
    /// Quality thresholds for deciding whether to accept a result or try the next backend.
    pub quality_thresholds: OcrQualityThresholds,
}

#[php_impl]
impl OcrPipelineConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_stages(&self) -> Vec<OcrPipelineStage> {
        self.stages.clone()
    }

    pub fn get_quality_thresholds(&self) -> OcrQualityThresholds {
        self.quality_thresholds.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\OcrConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrConfig {
    /// Whether OCR is enabled.
    ///
    /// Setting `enabled: false` is a shorthand for `disable_ocr: true` on the parent
    /// [`ExtractionConfig`](crate::core::config::ExtractionConfig). Images return
    /// metadata only; PDFs use native text extraction without OCR fallback.
    ///
    /// Defaults to `true`. When `false`, all other OCR settings are ignored.
    #[php(prop, name = "enabled")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enabled: bool,
    /// OCR backend: tesseract, easyocr, paddleocr
    #[php(prop, name = "backend")]
    pub backend: String,
    /// Language code (e.g., "eng", "deu")
    #[php(prop, name = "language")]
    pub language: String,
    /// Tesseract-specific configuration (optional)
    pub tesseract_config: Option<TesseractConfig>,
    /// Output format for OCR results (optional, for format conversion)
    #[php(prop, name = "outputFormat")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub output_format: Option<String>,
    /// PaddleOCR-specific configuration (optional, JSON passthrough)
    pub paddle_ocr_config: Option<serde_json::Value>,
    /// Arbitrary per-call options passed through to the backend unchanged.
    ///
    /// Custom OCR backends and built-in backends that support runtime tuning
    /// can read this value and deserialize the keys they care about. Keys
    /// unknown to the backend are silently ignored.
    ///
    /// This is the recommended extension point for per-call parameters that
    /// are not covered by the typed fields above (e.g. mode switching,
    /// preprocessing flags, inference batch size).
    ///
    /// **Scope:** when `pipeline` is `None`, this value is propagated to the
    /// primary stage of the auto-constructed pipeline. When `pipeline` is
    /// explicitly set, this field has **no effect** — the caller must set
    /// `OcrPipelineStage.backend_options` directly on the relevant stage(s)
    /// instead.
    ///
    /// Example:
    /// ```json
    /// { "mode": "fast", "enable_layout": true, "timeout_ms": 5000 }
    /// ```
    pub backend_options: Option<serde_json::Value>,
    /// OCR element extraction configuration
    pub element_config: Option<OcrElementConfig>,
    /// Quality thresholds for the native-text-to-OCR fallback decision.
    /// When None, uses compiled defaults (matching previous hardcoded behavior).
    pub quality_thresholds: Option<OcrQualityThresholds>,
    /// Multi-backend OCR pipeline configuration. When set, enables weighted
    /// fallback across multiple OCR backends based on output quality.
    /// When None, uses the single `backend` field (same as today).
    pub pipeline: Option<OcrPipelineConfig>,
    /// Enable automatic page rotation based on orientation detection.
    ///
    /// When enabled, uses Tesseract's `DetectOrientationScript()` to detect
    /// page orientation (0/90/180/270 degrees) before OCR. If the page is
    /// rotated with high confidence, the image is corrected before recognition.
    /// This is critical for handling rotated scanned documents.
    #[php(prop, name = "autoRotate")]
    pub auto_rotate: bool,
    /// VLM (Vision Language Model) OCR configuration.
    ///
    /// Required when `backend` is `"vlm"`. Uses liter-llm to send page
    /// images to a vision model for text extraction.
    pub vlm_config: Option<LlmConfig>,
    /// Custom Jinja2 prompt template for VLM OCR.
    ///
    /// When `None`, uses the default template. Available variables:
    /// - `{{ language }}` — The document language code (e.g., "eng", "deu").
    #[php(prop, name = "vlmPrompt")]
    pub vlm_prompt: Option<String>,
    /// Hardware acceleration for ONNX Runtime models (e.g. PaddleOCR, layout detection).
    ///
    /// Not user-configurable via config files — injected at runtime from
    /// `ExtractionConfig::acceleration` before each `process_image` call.
    pub acceleration: Option<AccelerationConfig>,
    /// Caller-supplied Tesseract `traineddata` bytes per language code.
    ///
    /// Primary use case is the WASM build, which has no filesystem and cannot
    /// download tessdata at runtime. Native builds typically rely on
    /// `TessdataManager` and ignore this field. When present, the WASM
    /// Tesseract backend prefers these bytes over its compile-time-bundled
    /// English data.
    ///
    /// Skipped by serde to keep config files small — supply via the typed API
    /// at runtime.
    pub tessdata_bytes: Option<HashMap<String, Vec<u8>>>,
}

impl Default for OcrConfig {
    fn default() -> Self {
        <kreuzberg::OcrConfig as Default>::default().into()
    }
}

#[php_impl]
impl OcrConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        enabled: bool,
        backend: String,
        language: String,
        outputFormat: Option<String>,
        autoRotate: bool,
        vlmPrompt: Option<String>,
    ) -> Self {
        Self {
            enabled,
            backend,
            language,
            tesseract_config: Default::default(),
            output_format: outputFormat,
            paddle_ocr_config: Default::default(),
            backend_options: Default::default(),
            element_config: Default::default(),
            quality_thresholds: Default::default(),
            pipeline: Default::default(),
            auto_rotate: autoRotate,
            vlm_config: Default::default(),
            vlm_prompt: vlmPrompt,
            acceleration: Default::default(),
            tessdata_bytes: Default::default(),
        }
    }

    pub fn get_enabled(&self) -> bool {
        self.enabled.clone()
    }

    pub fn get_backend(&self) -> String {
        self.backend.clone()
    }

    pub fn get_language(&self) -> String {
        self.language.clone()
    }

    pub fn get_tesseract_config(&self) -> Option<TesseractConfig> {
        self.tesseract_config.clone().map(Into::into)
    }

    pub fn get_output_format(&self) -> Option<String> {
        self.output_format.clone().map(Into::into)
    }

    pub fn get_paddle_ocr_config(&self) -> Option<String> {
        self.paddle_ocr_config
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }

    pub fn get_backend_options(&self) -> Option<String> {
        self.backend_options
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }

    pub fn get_element_config(&self) -> Option<OcrElementConfig> {
        self.element_config.clone().map(Into::into)
    }

    pub fn get_quality_thresholds(&self) -> Option<OcrQualityThresholds> {
        self.quality_thresholds.clone().map(Into::into)
    }

    pub fn get_pipeline(&self) -> Option<OcrPipelineConfig> {
        self.pipeline.clone().map(Into::into)
    }

    pub fn get_auto_rotate(&self) -> bool {
        self.auto_rotate.clone()
    }

    pub fn get_vlm_config(&self) -> Option<LlmConfig> {
        self.vlm_config.clone().map(Into::into)
    }

    pub fn get_vlm_prompt(&self) -> Option<String> {
        self.vlm_prompt.clone()
    }

    pub fn get_acceleration(&self) -> Option<AccelerationConfig> {
        self.acceleration.clone().map(Into::into)
    }

    pub fn get_tessdata_bytes(&self) -> Option<HashMap<String, Vec<u8>>> {
        self.tessdata_bytes.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> OcrConfig {
        kreuzberg::OcrConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PageConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct PageConfig {
    /// Extract pages as separate array (ExtractionResult.pages)
    #[php(prop, name = "extractPages")]
    pub extract_pages: bool,
    /// Insert page markers in main content string
    #[php(prop, name = "insertPageMarkers")]
    pub insert_page_markers: bool,
    /// Page marker format (use {page_num} placeholder)
    /// Default: "\n\n<!-- PAGE {page_num} -->\n\n"
    #[php(prop, name = "markerFormat")]
    pub marker_format: String,
}

impl Default for PageConfig {
    fn default() -> Self {
        <kreuzberg::PageConfig as Default>::default().into()
    }
}

#[php_impl]
impl PageConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(extractPages: bool, insertPageMarkers: bool, markerFormat: String) -> Self {
        Self {
            extract_pages: extractPages,
            insert_page_markers: insertPageMarkers,
            marker_format: markerFormat,
        }
    }

    pub fn get_extract_pages(&self) -> bool {
        self.extract_pages.clone()
    }

    pub fn get_insert_page_markers(&self) -> bool {
        self.insert_page_markers.clone()
    }

    pub fn get_marker_format(&self) -> String {
        self.marker_format.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> PageConfig {
        kreuzberg::PageConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PdfConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct PdfConfig {
    /// Extract images from PDF
    #[php(prop, name = "extractImages")]
    pub extract_images: bool,
    /// Extract tables from PDF.
    ///
    /// When `true` (default), runs pdf_oxide's native grid detector and, if it
    /// finds nothing, falls back to the heuristic text-layer reconstruction in
    /// `pdf::oxide::table::extract_tables_heuristic`. Set to `false` to skip
    /// both passes — `tables` will then be empty in the result.
    #[php(prop, name = "extractTables")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub extract_tables: bool,
    /// List of passwords to try when opening encrypted PDFs
    #[php(prop, name = "passwords")]
    pub passwords: Option<Vec<String>>,
    /// Extract PDF metadata
    #[php(prop, name = "extractMetadata")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub extract_metadata: bool,
    /// Hierarchy extraction configuration (None = hierarchy extraction disabled)
    pub hierarchy: Option<HierarchyConfig>,
    /// Extract PDF annotations (text notes, highlights, links, stamps).
    /// Default: false
    #[php(prop, name = "extractAnnotations")]
    pub extract_annotations: bool,
    /// Top margin fraction (0.0–1.0) of page height to exclude headers/running heads.
    /// Default: 0.06 (6%)
    #[php(prop, name = "topMarginFraction")]
    pub top_margin_fraction: Option<f32>,
    /// Bottom margin fraction (0.0–1.0) of page height to exclude footers/page numbers.
    /// Default: 0.05 (5%)
    #[php(prop, name = "bottomMarginFraction")]
    pub bottom_margin_fraction: Option<f32>,
    /// Allow single-column pseudo tables in extraction results.
    ///
    /// By default, tables with fewer than 2 columns (layout-guided) or 3 columns
    /// (heuristic) are rejected. When `true`, the minimum column count is relaxed
    /// to 1, allowing single-column structured data (glossaries, itemized lists)
    /// to be emitted as tables. Other quality filters (density, sparsity, prose
    /// detection) still apply.
    #[php(prop, name = "allowSingleColumnTables")]
    pub allow_single_column_tables: bool,
    /// Perform OCR on inline images extracted from PDF pages and attach the
    /// recognized text to each `ExtractedImage.ocr_result`. Requires Tesseract
    /// to be available; if `ExtractionConfig.ocr` is `None` the extractor
    /// falls back to `TesseractConfig::default()`. Per-image failures degrade
    /// gracefully (the image is returned without OCR text rather than failing
    /// the whole extraction). Default: `false`.
    #[php(prop, name = "ocrInlineImages")]
    pub ocr_inline_images: bool,
}

impl Default for PdfConfig {
    fn default() -> Self {
        <kreuzberg::PdfConfig as Default>::default().into()
    }
}

#[php_impl]
impl PdfConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        extractImages: bool,
        extractTables: bool,
        passwords: Option<Vec<String>>,
        extractMetadata: bool,
        extractAnnotations: bool,
        topMarginFraction: Option<f32>,
        bottomMarginFraction: Option<f32>,
        allowSingleColumnTables: bool,
        ocrInlineImages: bool,
    ) -> Self {
        Self {
            extract_images: extractImages,
            extract_tables: extractTables,
            passwords,
            extract_metadata: extractMetadata,
            hierarchy: Default::default(),
            extract_annotations: extractAnnotations,
            top_margin_fraction: topMarginFraction,
            bottom_margin_fraction: bottomMarginFraction,
            allow_single_column_tables: allowSingleColumnTables,
            ocr_inline_images: ocrInlineImages,
        }
    }

    pub fn get_extract_images(&self) -> bool {
        self.extract_images.clone()
    }

    pub fn get_extract_tables(&self) -> bool {
        self.extract_tables.clone()
    }

    pub fn get_passwords(&self) -> Option<Vec<String>> {
        self.passwords.clone()
    }

    pub fn get_extract_metadata(&self) -> bool {
        self.extract_metadata.clone()
    }

    pub fn get_hierarchy(&self) -> Option<HierarchyConfig> {
        self.hierarchy.clone().map(Into::into)
    }

    pub fn get_extract_annotations(&self) -> bool {
        self.extract_annotations.clone()
    }

    pub fn get_top_margin_fraction(&self) -> Option<f32> {
        self.top_margin_fraction.clone()
    }

    pub fn get_bottom_margin_fraction(&self) -> Option<f32> {
        self.bottom_margin_fraction.clone()
    }

    pub fn get_allow_single_column_tables(&self) -> bool {
        self.allow_single_column_tables.clone()
    }

    pub fn get_ocr_inline_images(&self) -> bool {
        self.ocr_inline_images.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> PdfConfig {
        kreuzberg::PdfConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\HierarchyConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct HierarchyConfig {
    /// Enable hierarchy extraction
    #[php(prop, name = "enabled")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enabled: bool,
    /// Number of font size clusters to use for hierarchy levels (1-7)
    ///
    /// Default: 6, which provides H1-H6 heading levels with body text.
    /// Larger values create more fine-grained hierarchy levels.
    #[php(prop, name = "kClusters")]
    pub k_clusters: i64,
    /// Include bounding box information in hierarchy blocks
    #[php(prop, name = "includeBbox")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub include_bbox: bool,
    /// OCR coverage threshold for smart OCR triggering (0.0-1.0)
    ///
    /// Determines when OCR should be triggered based on text block coverage.
    /// OCR is triggered when text blocks cover less than this fraction of the page.
    /// Default: 0.5 (trigger OCR if less than 50% of page has text)
    #[php(prop, name = "ocrCoverageThreshold")]
    pub ocr_coverage_threshold: Option<f32>,
}

impl Default for HierarchyConfig {
    fn default() -> Self {
        <kreuzberg::HierarchyConfig as Default>::default().into()
    }
}

#[php_impl]
impl HierarchyConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(enabled: bool, kClusters: i64, includeBbox: bool, ocrCoverageThreshold: Option<f32>) -> Self {
        Self {
            enabled,
            k_clusters: kClusters,
            include_bbox: includeBbox,
            ocr_coverage_threshold: ocrCoverageThreshold,
        }
    }

    pub fn get_enabled(&self) -> bool {
        self.enabled.clone()
    }

    pub fn get_k_clusters(&self) -> i64 {
        self.k_clusters.clone()
    }

    pub fn get_include_bbox(&self) -> bool {
        self.include_bbox.clone()
    }

    pub fn get_ocr_coverage_threshold(&self) -> Option<f32> {
        self.ocr_coverage_threshold.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> HierarchyConfig {
        kreuzberg::HierarchyConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PostProcessorConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct PostProcessorConfig {
    /// Enable post-processors
    #[php(prop, name = "enabled")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enabled: bool,
    /// Whitelist of processor names to run (None = all enabled)
    #[php(prop, name = "enabledProcessors")]
    pub enabled_processors: Option<Vec<String>>,
    /// Blacklist of processor names to skip (None = none disabled)
    #[php(prop, name = "disabledProcessors")]
    pub disabled_processors: Option<Vec<String>>,
    /// Pre-computed AHashSet for O(1) enabled processor lookup
    #[php(prop, name = "enabledSet")]
    pub enabled_set: Option<Vec<String>>,
    /// Pre-computed AHashSet for O(1) disabled processor lookup
    #[php(prop, name = "disabledSet")]
    pub disabled_set: Option<Vec<String>>,
}

impl Default for PostProcessorConfig {
    fn default() -> Self {
        <kreuzberg::PostProcessorConfig as Default>::default().into()
    }
}

#[php_impl]
impl PostProcessorConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        enabled: bool,
        enabledProcessors: Option<Vec<String>>,
        disabledProcessors: Option<Vec<String>>,
        enabledSet: Option<Vec<String>>,
        disabledSet: Option<Vec<String>>,
    ) -> Self {
        Self {
            enabled,
            enabled_processors: enabledProcessors,
            disabled_processors: disabledProcessors,
            enabled_set: enabledSet,
            disabled_set: disabledSet,
        }
    }

    pub fn get_enabled(&self) -> bool {
        self.enabled.clone()
    }

    pub fn get_enabled_processors(&self) -> Option<Vec<String>> {
        self.enabled_processors.clone()
    }

    pub fn get_disabled_processors(&self) -> Option<Vec<String>> {
        self.disabled_processors.clone()
    }

    pub fn get_enabled_set(&self) -> Option<Vec<String>> {
        self.enabled_set.clone()
    }

    pub fn get_disabled_set(&self) -> Option<Vec<String>> {
        self.disabled_set.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> PostProcessorConfig {
        kreuzberg::PostProcessorConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ChunkingConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct ChunkingConfig {
    /// Maximum size per chunk (in units determined by `sizing`).
    ///
    /// When `sizing` is `Characters` (default), this is the max character count.
    /// When using token-based sizing, this is the max token count.
    ///
    /// Default: 1000
    #[php(prop, name = "maxCharacters")]
    pub max_characters: i64,
    /// Overlap between chunks (in units determined by `sizing`).
    ///
    /// Default: 200
    #[php(prop, name = "overlap")]
    pub overlap: i64,
    /// Whether to trim whitespace from chunk boundaries.
    ///
    /// Default: true
    #[php(prop, name = "trim")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub trim: bool,
    /// Type of chunker to use (Text or Markdown).
    ///
    /// Default: Text
    #[php(prop, name = "chunkerType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub chunker_type: String,
    /// Optional embedding configuration for chunk embeddings.
    pub embedding: Option<EmbeddingConfig>,
    /// Use a preset configuration (overrides individual settings if provided).
    #[php(prop, name = "preset")]
    pub preset: Option<String>,
    /// How to measure chunk size.
    ///
    /// Default: `Characters` (Unicode character count).
    /// Enable `chunking-tiktoken` or `chunking-tokenizers` features for token-based sizing.
    pub sizing: ChunkSizing,
    /// When `true` and `chunker_type` is `Markdown`, prepend the heading hierarchy
    /// path (e.g. `"# Title > ## Section\n\n"`) to each chunk's content string.
    ///
    /// This is useful for RAG pipelines where each chunk needs self-contained
    /// context about its position in the document structure.
    ///
    /// Default: `false`
    #[php(prop, name = "prependHeadingContext")]
    pub prepend_heading_context: bool,
    /// Optional cosine similarity threshold for semantic topic boundary detection.
    ///
    /// Only used when `chunker_type` is `Semantic` and an `EmbeddingConfig` is
    /// provided. You almost never need to set this. When omitted, defaults to
    /// `0.75` which works well for most documents. Lower values detect more
    /// topic boundaries (more, smaller chunks); higher values detect fewer.
    /// Range: `0.0..=1.0`.
    #[php(prop, name = "topicThreshold")]
    pub topic_threshold: Option<f32>,
}

impl Default for ChunkingConfig {
    fn default() -> Self {
        <kreuzberg::ChunkingConfig as Default>::default().into()
    }
}

#[php_impl]
impl ChunkingConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        maxCharacters: i64,
        overlap: i64,
        trim: bool,
        chunkerType: String,
        preset: Option<String>,
        prependHeadingContext: bool,
        topicThreshold: Option<f32>,
    ) -> Self {
        Self {
            max_characters: maxCharacters,
            overlap,
            trim,
            chunker_type: chunkerType,
            embedding: Default::default(),
            preset,
            sizing: Default::default(),
            prepend_heading_context: prependHeadingContext,
            topic_threshold: topicThreshold,
        }
    }

    pub fn get_max_characters(&self) -> i64 {
        self.max_characters.clone()
    }

    pub fn get_overlap(&self) -> i64 {
        self.overlap.clone()
    }

    pub fn get_trim(&self) -> bool {
        self.trim.clone()
    }

    pub fn get_chunker_type(&self) -> String {
        self.chunker_type.clone()
    }

    pub fn get_embedding(&self) -> Option<EmbeddingConfig> {
        self.embedding.clone().map(Into::into)
    }

    pub fn get_preset(&self) -> Option<String> {
        self.preset.clone()
    }

    pub fn get_sizing(&self) -> ChunkSizing {
        self.sizing.clone()
    }

    pub fn get_prepend_heading_context(&self) -> bool {
        self.prepend_heading_context.clone()
    }

    pub fn get_topic_threshold(&self) -> Option<f32> {
        self.topic_threshold.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> ChunkingConfig {
        kreuzberg::ChunkingConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\EmbeddingConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct EmbeddingConfig {
    /// The embedding model to use (defaults to "balanced" preset if not specified)
    pub model: EmbeddingModelType,
    /// Whether to normalize embedding vectors (recommended for cosine similarity)
    #[php(prop, name = "normalize")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub normalize: bool,
    /// Batch size for embedding generation
    #[php(prop, name = "batchSize")]
    pub batch_size: i64,
    /// Show model download progress
    #[php(prop, name = "showDownloadProgress")]
    pub show_download_progress: bool,
    /// Custom cache directory for model files
    ///
    /// Defaults to `~/.cache/kreuzberg/embeddings/` if not specified.
    /// Allows full customization of model download location.
    #[php(prop, name = "cacheDir")]
    pub cache_dir: Option<String>,
    /// Hardware acceleration for the embedding ONNX model.
    ///
    /// When set, controls which execution provider (CPU, CUDA, CoreML, TensorRT)
    /// is used for inference. Defaults to `None` (auto-select per platform).
    pub acceleration: Option<AccelerationConfig>,
    /// Maximum wall-clock duration (in seconds) for a single `embed()` call when
    /// using [`EmbeddingModelType::Plugin`].
    ///
    /// Applies only to the in-process plugin path — protects against hung
    /// host-language backends (e.g. a Python callback deadlocked on the GIL,
    /// a model stuck on CUDA OOM retries, etc.). On timeout, the dispatcher
    /// returns `Plugin` instead of blocking forever.
    ///
    /// `None` disables the timeout. The default (60 seconds) is conservative
    /// for common in-process inference; increase for large batches on slow
    /// hardware.
    #[php(prop, name = "maxEmbedDurationSecs")]
    pub max_embed_duration_secs: Option<i64>,
}

impl Default for EmbeddingConfig {
    fn default() -> Self {
        <kreuzberg::EmbeddingConfig as Default>::default().into()
    }
}

#[php_impl]
impl EmbeddingConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        normalize: bool,
        batchSize: i64,
        showDownloadProgress: bool,
        cacheDir: Option<String>,
        maxEmbedDurationSecs: Option<i64>,
    ) -> Self {
        Self {
            model: Default::default(),
            normalize,
            batch_size: batchSize,
            show_download_progress: showDownloadProgress,
            cache_dir: cacheDir,
            acceleration: Default::default(),
            max_embed_duration_secs: maxEmbedDurationSecs,
        }
    }

    pub fn get_model(&self) -> EmbeddingModelType {
        self.model.clone()
    }

    pub fn get_normalize(&self) -> bool {
        self.normalize.clone()
    }

    pub fn get_batch_size(&self) -> i64 {
        self.batch_size.clone()
    }

    pub fn get_show_download_progress(&self) -> bool {
        self.show_download_progress.clone()
    }

    pub fn get_cache_dir(&self) -> Option<String> {
        self.cache_dir.clone()
    }

    pub fn get_acceleration(&self) -> Option<AccelerationConfig> {
        self.acceleration.clone().map(Into::into)
    }

    pub fn get_max_embed_duration_secs(&self) -> Option<i64> {
        self.max_embed_duration_secs.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> EmbeddingConfig {
        kreuzberg::EmbeddingConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TreeSitterConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct TreeSitterConfig {
    /// Enable code intelligence processing (default: true).
    ///
    /// When `false`, tree-sitter analysis is completely skipped even if
    /// the config section is present.
    #[php(prop, name = "enabled")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enabled: bool,
    /// Custom cache directory for downloaded grammars.
    ///
    /// When `None`, uses the default: `~/.cache/tree-sitter-language-pack/v{version}/libs/`.
    #[php(prop, name = "cacheDir")]
    pub cache_dir: Option<String>,
    /// Languages to pre-download on init (e.g., `["python", "rust"]`).
    #[php(prop, name = "languages")]
    pub languages: Option<Vec<String>>,
    /// Language groups to pre-download (e.g., `["web", "systems", "scripting"]`).
    #[php(prop, name = "groups")]
    pub groups: Option<Vec<String>>,
    /// Processing options for code analysis.
    pub process: TreeSitterProcessConfig,
}

impl Default for TreeSitterConfig {
    fn default() -> Self {
        <kreuzberg::TreeSitterConfig as Default>::default().into()
    }
}

#[php_impl]
impl TreeSitterConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        enabled: bool,
        cacheDir: Option<String>,
        languages: Option<Vec<String>>,
        groups: Option<Vec<String>>,
    ) -> Self {
        Self {
            enabled,
            cache_dir: cacheDir,
            languages,
            groups,
            process: Default::default(),
        }
    }

    pub fn get_enabled(&self) -> bool {
        self.enabled.clone()
    }

    pub fn get_cache_dir(&self) -> Option<String> {
        self.cache_dir.clone()
    }

    pub fn get_languages(&self) -> Option<Vec<String>> {
        self.languages.clone()
    }

    pub fn get_groups(&self) -> Option<Vec<String>> {
        self.groups.clone()
    }

    pub fn get_process(&self) -> TreeSitterProcessConfig {
        self.process.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> TreeSitterConfig {
        kreuzberg::TreeSitterConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TreeSitterProcessConfig")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct TreeSitterProcessConfig {
    /// Extract structural items (functions, classes, structs, etc.). Default: true.
    #[php(prop, name = "structure")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub structure: bool,
    /// Extract import statements. Default: true.
    #[php(prop, name = "imports")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub imports: bool,
    /// Extract export statements. Default: true.
    #[php(prop, name = "exports")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub exports: bool,
    /// Extract comments. Default: false.
    #[php(prop, name = "comments")]
    pub comments: bool,
    /// Extract docstrings. Default: false.
    #[php(prop, name = "docstrings")]
    pub docstrings: bool,
    /// Extract symbol definitions. Default: false.
    #[php(prop, name = "symbols")]
    pub symbols: bool,
    /// Include parse diagnostics. Default: false.
    #[php(prop, name = "diagnostics")]
    pub diagnostics: bool,
    /// Maximum chunk size in bytes. `None` disables chunking.
    #[php(prop, name = "chunkMaxSize")]
    pub chunk_max_size: Option<i64>,
    /// Content rendering mode for code extraction.
    #[php(prop, name = "contentMode")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub content_mode: String,
}

impl Default for TreeSitterProcessConfig {
    fn default() -> Self {
        <kreuzberg::TreeSitterProcessConfig as Default>::default().into()
    }
}

#[php_impl]
impl TreeSitterProcessConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        structure: bool,
        imports: bool,
        exports: bool,
        comments: bool,
        docstrings: bool,
        symbols: bool,
        diagnostics: bool,
        chunkMaxSize: Option<i64>,
        contentMode: String,
    ) -> Self {
        Self {
            structure,
            imports,
            exports,
            comments,
            docstrings,
            symbols,
            diagnostics,
            chunk_max_size: chunkMaxSize,
            content_mode: contentMode,
        }
    }

    pub fn get_structure(&self) -> bool {
        self.structure.clone()
    }

    pub fn get_imports(&self) -> bool {
        self.imports.clone()
    }

    pub fn get_exports(&self) -> bool {
        self.exports.clone()
    }

    pub fn get_comments(&self) -> bool {
        self.comments.clone()
    }

    pub fn get_docstrings(&self) -> bool {
        self.docstrings.clone()
    }

    pub fn get_symbols(&self) -> bool {
        self.symbols.clone()
    }

    pub fn get_diagnostics(&self) -> bool {
        self.diagnostics.clone()
    }

    pub fn get_chunk_max_size(&self) -> Option<i64> {
        self.chunk_max_size.clone()
    }

    pub fn get_content_mode(&self) -> String {
        self.content_mode.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> TreeSitterProcessConfig {
        kreuzberg::TreeSitterProcessConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\SupportedFormat")]
#[serde(default, rename_all = "camelCase")]
pub struct SupportedFormat {
    /// File extension (without leading dot), e.g., "pdf", "docx"
    #[php(prop, name = "extension")]
    pub extension: String,
    /// MIME type string, e.g., "application/pdf"
    #[php(prop, name = "mimeType")]
    pub mime_type: String,
}

#[php_impl]
impl SupportedFormat {
    #[php(constructor)]
    pub fn new(extension: String, mimeType: String) -> Self {
        Self {
            extension,
            mime_type: mimeType,
        }
    }

    pub fn get_extension(&self) -> String {
        self.extension.clone()
    }

    pub fn get_mime_type(&self) -> String {
        self.mime_type.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ServerConfig")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct ServerConfig {
    /// Server host address (e.g., "127.0.0.1", "0.0.0.0")
    #[php(prop, name = "host")]
    pub host: String,
    /// Server port number
    #[php(prop, name = "port")]
    pub port: u16,
    /// CORS allowed origins. Empty vector means allow all origins.
    ///
    /// If this is an empty vector, the server will accept requests from any origin.
    /// If populated with specific origins (e.g., `"https://example.com"`), only
    /// those origins will be allowed.
    #[php(prop, name = "corsOrigins")]
    pub cors_origins: Vec<String>,
    /// Maximum size of request body in bytes (default: 100 MB)
    #[php(prop, name = "maxRequestBodyBytes")]
    pub max_request_body_bytes: i64,
    /// Maximum size of multipart fields in bytes (default: 100 MB)
    #[php(prop, name = "maxMultipartFieldBytes")]
    pub max_multipart_field_bytes: i64,
}

impl Default for ServerConfig {
    fn default() -> Self {
        <kreuzberg::ServerConfig as Default>::default().into()
    }
}

#[php_impl]
impl ServerConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        host: String,
        port: u16,
        corsOrigins: Vec<String>,
        maxRequestBodyBytes: i64,
        maxMultipartFieldBytes: i64,
    ) -> Self {
        Self {
            host,
            port,
            cors_origins: corsOrigins,
            max_request_body_bytes: maxRequestBodyBytes,
            max_multipart_field_bytes: maxMultipartFieldBytes,
        }
    }

    pub fn get_host(&self) -> String {
        self.host.clone()
    }

    pub fn get_port(&self) -> u16 {
        self.port.clone()
    }

    pub fn get_cors_origins(&self) -> Vec<String> {
        self.cors_origins.clone()
    }

    pub fn get_max_request_body_bytes(&self) -> i64 {
        self.max_request_body_bytes.clone()
    }

    pub fn get_max_multipart_field_bytes(&self) -> i64 {
        self.max_multipart_field_bytes.clone()
    }

    pub fn listen_addr(&self) -> String {
        let core_self = kreuzberg::ServerConfig {
            host: self.host.clone(),
            port: self.port,
            cors_origins: self.cors_origins.clone(),
            max_request_body_bytes: self.max_request_body_bytes as usize,
            max_multipart_field_bytes: self.max_multipart_field_bytes as usize,
        };
        core_self.listen_addr()
    }

    pub fn cors_allows_all(&self) -> bool {
        let core_self = kreuzberg::ServerConfig {
            host: self.host.clone(),
            port: self.port,
            cors_origins: self.cors_origins.clone(),
            max_request_body_bytes: self.max_request_body_bytes as usize,
            max_multipart_field_bytes: self.max_multipart_field_bytes as usize,
        };
        core_self.cors_allows_all()
    }

    pub fn is_origin_allowed(&self, origin: String) -> bool {
        let core_self = kreuzberg::ServerConfig {
            host: self.host.clone(),
            port: self.port,
            cors_origins: self.cors_origins.clone(),
            max_request_body_bytes: self.max_request_body_bytes as usize,
            max_multipart_field_bytes: self.max_multipart_field_bytes as usize,
        };
        core_self.is_origin_allowed(&origin)
    }

    pub fn max_request_body_mb(&self) -> i64 {
        let core_self = kreuzberg::ServerConfig {
            host: self.host.clone(),
            port: self.port,
            cors_origins: self.cors_origins.clone(),
            max_request_body_bytes: self.max_request_body_bytes as usize,
            max_multipart_field_bytes: self.max_multipart_field_bytes as usize,
        };
        core_self.max_request_body_mb() as i64
    }

    pub fn max_multipart_field_mb(&self) -> i64 {
        let core_self = kreuzberg::ServerConfig {
            host: self.host.clone(),
            port: self.port,
            cors_origins: self.cors_origins.clone(),
            max_request_body_bytes: self.max_request_body_bytes as usize,
            max_multipart_field_bytes: self.max_multipart_field_bytes as usize,
        };
        core_self.max_multipart_field_mb() as i64
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> ServerConfig {
        kreuzberg::ServerConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\StructuredDataResult")]
#[serde(default, rename_all = "camelCase")]
pub struct StructuredDataResult {
    #[php(prop, name = "content")]
    pub content: String,
    #[php(prop, name = "format")]
    pub format: String,
    pub metadata: HashMap<String, String>,
    #[php(prop, name = "textFields")]
    pub text_fields: Vec<String>,
}

#[php_impl]
impl StructuredDataResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: String, format: String, textFields: Vec<String>) -> Self {
        Self {
            content,
            format,
            metadata: Default::default(),
            text_fields: textFields,
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_format(&self) -> String {
        self.format.clone()
    }

    pub fn get_metadata(&self) -> HashMap<String, String> {
        self.metadata.clone()
    }

    pub fn get_text_fields(&self) -> Vec<String> {
        self.text_fields.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\DocxAppProperties")]
#[serde(default, rename_all = "camelCase")]
pub struct DocxAppProperties {
    /// Application name (e.g., "Microsoft Office Word")
    #[php(prop, name = "application")]
    pub application: Option<String>,
    /// Application version
    #[php(prop, name = "appVersion")]
    pub app_version: Option<String>,
    /// Template filename
    #[php(prop, name = "template")]
    pub template: Option<String>,
    /// Total editing time in minutes
    #[php(prop, name = "totalTime")]
    pub total_time: Option<i32>,
    /// Number of pages
    #[php(prop, name = "pages")]
    pub pages: Option<i32>,
    /// Number of words
    #[php(prop, name = "words")]
    pub words: Option<i32>,
    /// Number of characters (excluding spaces)
    #[php(prop, name = "characters")]
    pub characters: Option<i32>,
    /// Number of characters (including spaces)
    #[php(prop, name = "charactersWithSpaces")]
    pub characters_with_spaces: Option<i32>,
    /// Number of lines
    #[php(prop, name = "lines")]
    pub lines: Option<i32>,
    /// Number of paragraphs
    #[php(prop, name = "paragraphs")]
    pub paragraphs: Option<i32>,
    /// Company name
    #[php(prop, name = "company")]
    pub company: Option<String>,
    /// Document security level
    #[php(prop, name = "docSecurity")]
    pub doc_security: Option<i32>,
    /// Scale crop flag
    #[php(prop, name = "scaleCrop")]
    pub scale_crop: Option<bool>,
    /// Links up to date flag
    #[php(prop, name = "linksUpToDate")]
    pub links_up_to_date: Option<bool>,
    /// Shared document flag
    #[php(prop, name = "sharedDoc")]
    pub shared_doc: Option<bool>,
    /// Hyperlinks changed flag
    #[php(prop, name = "hyperlinksChanged")]
    pub hyperlinks_changed: Option<bool>,
}

impl Default for DocxAppProperties {
    fn default() -> Self {
        <kreuzberg::DocxAppProperties as Default>::default().into()
    }
}

#[php_impl]
impl DocxAppProperties {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_application(&self) -> Option<String> {
        self.application.clone()
    }

    pub fn get_app_version(&self) -> Option<String> {
        self.app_version.clone()
    }

    pub fn get_template(&self) -> Option<String> {
        self.template.clone()
    }

    pub fn get_total_time(&self) -> Option<i32> {
        self.total_time.clone()
    }

    pub fn get_pages(&self) -> Option<i32> {
        self.pages.clone()
    }

    pub fn get_words(&self) -> Option<i32> {
        self.words.clone()
    }

    pub fn get_characters(&self) -> Option<i32> {
        self.characters.clone()
    }

    pub fn get_characters_with_spaces(&self) -> Option<i32> {
        self.characters_with_spaces.clone()
    }

    pub fn get_lines(&self) -> Option<i32> {
        self.lines.clone()
    }

    pub fn get_paragraphs(&self) -> Option<i32> {
        self.paragraphs.clone()
    }

    pub fn get_company(&self) -> Option<String> {
        self.company.clone()
    }

    pub fn get_doc_security(&self) -> Option<i32> {
        self.doc_security.clone()
    }

    pub fn get_scale_crop(&self) -> Option<bool> {
        self.scale_crop.clone()
    }

    pub fn get_links_up_to_date(&self) -> Option<bool> {
        self.links_up_to_date.clone()
    }

    pub fn get_shared_doc(&self) -> Option<bool> {
        self.shared_doc.clone()
    }

    pub fn get_hyperlinks_changed(&self) -> Option<bool> {
        self.hyperlinks_changed.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\XlsxAppProperties")]
#[serde(default, rename_all = "camelCase")]
pub struct XlsxAppProperties {
    /// Application name (e.g., "Microsoft Excel")
    #[php(prop, name = "application")]
    pub application: Option<String>,
    /// Application version
    #[php(prop, name = "appVersion")]
    pub app_version: Option<String>,
    /// Document security level
    #[php(prop, name = "docSecurity")]
    pub doc_security: Option<i32>,
    /// Scale crop flag
    #[php(prop, name = "scaleCrop")]
    pub scale_crop: Option<bool>,
    /// Links up to date flag
    #[php(prop, name = "linksUpToDate")]
    pub links_up_to_date: Option<bool>,
    /// Shared document flag
    #[php(prop, name = "sharedDoc")]
    pub shared_doc: Option<bool>,
    /// Hyperlinks changed flag
    #[php(prop, name = "hyperlinksChanged")]
    pub hyperlinks_changed: Option<bool>,
    /// Company name
    #[php(prop, name = "company")]
    pub company: Option<String>,
    /// Worksheet names
    #[php(prop, name = "worksheetNames")]
    pub worksheet_names: Vec<String>,
}

impl Default for XlsxAppProperties {
    fn default() -> Self {
        <kreuzberg::extraction::office_metadata::app_properties::XlsxAppProperties as Default>::default().into()
    }
}

#[php_impl]
impl XlsxAppProperties {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        application: Option<String>,
        appVersion: Option<String>,
        docSecurity: Option<i32>,
        scaleCrop: Option<bool>,
        linksUpToDate: Option<bool>,
        sharedDoc: Option<bool>,
        hyperlinksChanged: Option<bool>,
        company: Option<String>,
        worksheetNames: Vec<String>,
    ) -> Self {
        Self {
            application,
            app_version: appVersion,
            doc_security: docSecurity,
            scale_crop: scaleCrop,
            links_up_to_date: linksUpToDate,
            shared_doc: sharedDoc,
            hyperlinks_changed: hyperlinksChanged,
            company,
            worksheet_names: worksheetNames,
        }
    }

    pub fn get_application(&self) -> Option<String> {
        self.application.clone()
    }

    pub fn get_app_version(&self) -> Option<String> {
        self.app_version.clone()
    }

    pub fn get_doc_security(&self) -> Option<i32> {
        self.doc_security.clone()
    }

    pub fn get_scale_crop(&self) -> Option<bool> {
        self.scale_crop.clone()
    }

    pub fn get_links_up_to_date(&self) -> Option<bool> {
        self.links_up_to_date.clone()
    }

    pub fn get_shared_doc(&self) -> Option<bool> {
        self.shared_doc.clone()
    }

    pub fn get_hyperlinks_changed(&self) -> Option<bool> {
        self.hyperlinks_changed.clone()
    }

    pub fn get_company(&self) -> Option<String> {
        self.company.clone()
    }

    pub fn get_worksheet_names(&self) -> Vec<String> {
        self.worksheet_names.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PptxAppProperties")]
#[serde(default, rename_all = "camelCase")]
pub struct PptxAppProperties {
    /// Application name (e.g., "Microsoft Office PowerPoint")
    #[php(prop, name = "application")]
    pub application: Option<String>,
    /// Application version
    #[php(prop, name = "appVersion")]
    pub app_version: Option<String>,
    /// Total editing time in minutes
    #[php(prop, name = "totalTime")]
    pub total_time: Option<i32>,
    /// Company name
    #[php(prop, name = "company")]
    pub company: Option<String>,
    /// Document security level
    #[php(prop, name = "docSecurity")]
    pub doc_security: Option<i32>,
    /// Scale crop flag
    #[php(prop, name = "scaleCrop")]
    pub scale_crop: Option<bool>,
    /// Links up to date flag
    #[php(prop, name = "linksUpToDate")]
    pub links_up_to_date: Option<bool>,
    /// Shared document flag
    #[php(prop, name = "sharedDoc")]
    pub shared_doc: Option<bool>,
    /// Hyperlinks changed flag
    #[php(prop, name = "hyperlinksChanged")]
    pub hyperlinks_changed: Option<bool>,
    /// Number of slides
    #[php(prop, name = "slides")]
    pub slides: Option<i32>,
    /// Number of notes
    #[php(prop, name = "notes")]
    pub notes: Option<i32>,
    /// Number of hidden slides
    #[php(prop, name = "hiddenSlides")]
    pub hidden_slides: Option<i32>,
    /// Number of multimedia clips
    #[php(prop, name = "multimediaClips")]
    pub multimedia_clips: Option<i32>,
    /// Presentation format (e.g., "Widescreen", "Standard")
    #[php(prop, name = "presentationFormat")]
    pub presentation_format: Option<String>,
    /// Slide titles
    #[php(prop, name = "slideTitles")]
    pub slide_titles: Vec<String>,
}

impl Default for PptxAppProperties {
    fn default() -> Self {
        <kreuzberg::extraction::office_metadata::app_properties::PptxAppProperties as Default>::default().into()
    }
}

#[php_impl]
impl PptxAppProperties {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        application: Option<String>,
        appVersion: Option<String>,
        totalTime: Option<i32>,
        company: Option<String>,
        docSecurity: Option<i32>,
        scaleCrop: Option<bool>,
        linksUpToDate: Option<bool>,
        sharedDoc: Option<bool>,
        hyperlinksChanged: Option<bool>,
        slides: Option<i32>,
        notes: Option<i32>,
        hiddenSlides: Option<i32>,
        multimediaClips: Option<i32>,
        presentationFormat: Option<String>,
        slideTitles: Vec<String>,
    ) -> Self {
        Self {
            application,
            app_version: appVersion,
            total_time: totalTime,
            company,
            doc_security: docSecurity,
            scale_crop: scaleCrop,
            links_up_to_date: linksUpToDate,
            shared_doc: sharedDoc,
            hyperlinks_changed: hyperlinksChanged,
            slides,
            notes,
            hidden_slides: hiddenSlides,
            multimedia_clips: multimediaClips,
            presentation_format: presentationFormat,
            slide_titles: slideTitles,
        }
    }

    pub fn get_application(&self) -> Option<String> {
        self.application.clone()
    }

    pub fn get_app_version(&self) -> Option<String> {
        self.app_version.clone()
    }

    pub fn get_total_time(&self) -> Option<i32> {
        self.total_time.clone()
    }

    pub fn get_company(&self) -> Option<String> {
        self.company.clone()
    }

    pub fn get_doc_security(&self) -> Option<i32> {
        self.doc_security.clone()
    }

    pub fn get_scale_crop(&self) -> Option<bool> {
        self.scale_crop.clone()
    }

    pub fn get_links_up_to_date(&self) -> Option<bool> {
        self.links_up_to_date.clone()
    }

    pub fn get_shared_doc(&self) -> Option<bool> {
        self.shared_doc.clone()
    }

    pub fn get_hyperlinks_changed(&self) -> Option<bool> {
        self.hyperlinks_changed.clone()
    }

    pub fn get_slides(&self) -> Option<i32> {
        self.slides.clone()
    }

    pub fn get_notes(&self) -> Option<i32> {
        self.notes.clone()
    }

    pub fn get_hidden_slides(&self) -> Option<i32> {
        self.hidden_slides.clone()
    }

    pub fn get_multimedia_clips(&self) -> Option<i32> {
        self.multimedia_clips.clone()
    }

    pub fn get_presentation_format(&self) -> Option<String> {
        self.presentation_format.clone()
    }

    pub fn get_slide_titles(&self) -> Vec<String> {
        self.slide_titles.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\CoreProperties")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct CoreProperties {
    /// Document title
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Document subject/topic
    #[php(prop, name = "subject")]
    pub subject: Option<String>,
    /// Document creator/author
    #[php(prop, name = "creator")]
    pub creator: Option<String>,
    /// Keywords or tags
    #[php(prop, name = "keywords")]
    pub keywords: Option<String>,
    /// Document description/abstract
    #[php(prop, name = "description")]
    pub description: Option<String>,
    /// User who last modified the document
    #[php(prop, name = "lastModifiedBy")]
    pub last_modified_by: Option<String>,
    /// Revision number
    #[php(prop, name = "revision")]
    pub revision: Option<String>,
    /// Creation timestamp (ISO 8601)
    #[php(prop, name = "created")]
    pub created: Option<String>,
    /// Last modification timestamp (ISO 8601)
    #[php(prop, name = "modified")]
    pub modified: Option<String>,
    /// Document category
    #[php(prop, name = "category")]
    pub category: Option<String>,
    /// Content status (Draft, Final, etc.)
    #[php(prop, name = "contentStatus")]
    pub content_status: Option<String>,
    /// Document language
    #[php(prop, name = "language")]
    pub language: Option<String>,
    /// Unique identifier
    #[php(prop, name = "identifier")]
    pub identifier: Option<String>,
    /// Document version
    #[php(prop, name = "version")]
    pub version: Option<String>,
    /// Last print timestamp (ISO 8601)
    #[php(prop, name = "lastPrinted")]
    pub last_printed: Option<String>,
}

impl Default for CoreProperties {
    fn default() -> Self {
        <kreuzberg::CoreProperties as Default>::default().into()
    }
}

#[php_impl]
impl CoreProperties {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_subject(&self) -> Option<String> {
        self.subject.clone()
    }

    pub fn get_creator(&self) -> Option<String> {
        self.creator.clone()
    }

    pub fn get_keywords(&self) -> Option<String> {
        self.keywords.clone()
    }

    pub fn get_description(&self) -> Option<String> {
        self.description.clone()
    }

    pub fn get_last_modified_by(&self) -> Option<String> {
        self.last_modified_by.clone()
    }

    pub fn get_revision(&self) -> Option<String> {
        self.revision.clone()
    }

    pub fn get_created(&self) -> Option<String> {
        self.created.clone()
    }

    pub fn get_modified(&self) -> Option<String> {
        self.modified.clone()
    }

    pub fn get_category(&self) -> Option<String> {
        self.category.clone()
    }

    pub fn get_content_status(&self) -> Option<String> {
        self.content_status.clone()
    }

    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    pub fn get_identifier(&self) -> Option<String> {
        self.identifier.clone()
    }

    pub fn get_version(&self) -> Option<String> {
        self.version.clone()
    }

    pub fn get_last_printed(&self) -> Option<String> {
        self.last_printed.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\SecurityLimits")]
#[serde(default, rename_all = "camelCase")]
pub struct SecurityLimits {
    /// Maximum uncompressed size for archives (500 MB)
    #[php(prop, name = "maxArchiveSize")]
    #[serde(default = "crate::serde_defaults::max_archive_size")]
    pub max_archive_size: i64,
    /// Maximum compression ratio before flagging as potential bomb (100:1)
    #[php(prop, name = "maxCompressionRatio")]
    #[serde(default = "crate::serde_defaults::max_compression_ratio")]
    pub max_compression_ratio: i64,
    /// Maximum number of files in archive (10,000)
    #[php(prop, name = "maxFilesInArchive")]
    #[serde(default = "crate::serde_defaults::max_files_in_archive")]
    pub max_files_in_archive: i64,
    /// Maximum nesting depth for structures (100)
    #[php(prop, name = "maxNestingDepth")]
    #[serde(default = "crate::serde_defaults::max_nesting_depth")]
    pub max_nesting_depth: i64,
    /// Maximum length of any single XML entity / attribute / token (1 MiB).
    /// This is a per-token cap, NOT a total cap — billion-laughs class
    /// attacks where a single entity expands to hundreds of MB are caught
    /// here, while normal long text content (a paragraph, a CDATA block) is
    /// caught by `max_content_size` instead.
    #[php(prop, name = "maxEntityLength")]
    #[serde(default = "crate::serde_defaults::max_entity_length")]
    pub max_entity_length: i64,
    /// Maximum string growth per document (100 MB)
    #[php(prop, name = "maxContentSize")]
    #[serde(default = "crate::serde_defaults::max_content_size")]
    pub max_content_size: i64,
    /// Maximum iterations per operation
    #[php(prop, name = "maxIterations")]
    #[serde(default = "crate::serde_defaults::max_iterations")]
    pub max_iterations: i64,
    /// Maximum XML depth (100 levels)
    #[php(prop, name = "maxXmlDepth")]
    #[serde(default = "crate::serde_defaults::max_xml_depth")]
    pub max_xml_depth: i64,
    /// Maximum cells per table (100,000)
    #[php(prop, name = "maxTableCells")]
    #[serde(default = "crate::serde_defaults::max_table_cells")]
    pub max_table_cells: i64,
}

impl Default for SecurityLimits {
    fn default() -> Self {
        <kreuzberg::SecurityLimits as Default>::default().into()
    }
}

#[php_impl]
impl SecurityLimits {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        maxArchiveSize: i64,
        maxCompressionRatio: i64,
        maxFilesInArchive: i64,
        maxNestingDepth: i64,
        maxEntityLength: i64,
        maxContentSize: i64,
        maxIterations: i64,
        maxXmlDepth: i64,
        maxTableCells: i64,
    ) -> Self {
        Self {
            max_archive_size: maxArchiveSize,
            max_compression_ratio: maxCompressionRatio,
            max_files_in_archive: maxFilesInArchive,
            max_nesting_depth: maxNestingDepth,
            max_entity_length: maxEntityLength,
            max_content_size: maxContentSize,
            max_iterations: maxIterations,
            max_xml_depth: maxXmlDepth,
            max_table_cells: maxTableCells,
        }
    }

    pub fn get_max_archive_size(&self) -> i64 {
        self.max_archive_size.clone()
    }

    pub fn get_max_compression_ratio(&self) -> i64 {
        self.max_compression_ratio.clone()
    }

    pub fn get_max_files_in_archive(&self) -> i64 {
        self.max_files_in_archive.clone()
    }

    pub fn get_max_nesting_depth(&self) -> i64 {
        self.max_nesting_depth.clone()
    }

    pub fn get_max_entity_length(&self) -> i64 {
        self.max_entity_length.clone()
    }

    pub fn get_max_content_size(&self) -> i64 {
        self.max_content_size.clone()
    }

    pub fn get_max_iterations(&self) -> i64 {
        self.max_iterations.clone()
    }

    pub fn get_max_xml_depth(&self) -> i64 {
        self.max_xml_depth.clone()
    }

    pub fn get_max_table_cells(&self) -> i64 {
        self.max_table_cells.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> SecurityLimits {
        kreuzberg::SecurityLimits::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TokenReductionConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct TokenReductionConfig {
    #[php(prop, name = "level")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub level: String,
    #[php(prop, name = "languageHint")]
    pub language_hint: Option<String>,
    #[php(prop, name = "preserveMarkdown")]
    pub preserve_markdown: bool,
    #[php(prop, name = "preserveCode")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub preserve_code: bool,
    #[php(prop, name = "semanticThreshold")]
    pub semantic_threshold: f32,
    #[php(prop, name = "enableParallel")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enable_parallel: bool,
    #[php(prop, name = "useSimd")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub use_simd: bool,
    pub custom_stopwords: Option<HashMap<String, Vec<String>>>,
    #[php(prop, name = "preservePatterns")]
    pub preserve_patterns: Vec<String>,
    #[php(prop, name = "targetReduction")]
    pub target_reduction: Option<f32>,
    #[php(prop, name = "enableSemanticClustering")]
    pub enable_semantic_clustering: bool,
}

impl Default for TokenReductionConfig {
    fn default() -> Self {
        <kreuzberg::TokenReductionConfig as Default>::default().into()
    }
}

#[php_impl]
impl TokenReductionConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        level: String,
        languageHint: Option<String>,
        preserveMarkdown: bool,
        preserveCode: bool,
        semanticThreshold: f32,
        enableParallel: bool,
        useSimd: bool,
        preservePatterns: Vec<String>,
        targetReduction: Option<f32>,
        enableSemanticClustering: bool,
    ) -> Self {
        Self {
            level,
            language_hint: languageHint,
            preserve_markdown: preserveMarkdown,
            preserve_code: preserveCode,
            semantic_threshold: semanticThreshold,
            enable_parallel: enableParallel,
            use_simd: useSimd,
            custom_stopwords: Default::default(),
            preserve_patterns: preservePatterns,
            target_reduction: targetReduction,
            enable_semantic_clustering: enableSemanticClustering,
        }
    }

    pub fn get_level(&self) -> String {
        self.level.clone()
    }

    pub fn get_language_hint(&self) -> Option<String> {
        self.language_hint.clone()
    }

    pub fn get_preserve_markdown(&self) -> bool {
        self.preserve_markdown.clone()
    }

    pub fn get_preserve_code(&self) -> bool {
        self.preserve_code.clone()
    }

    pub fn get_semantic_threshold(&self) -> f32 {
        self.semantic_threshold.clone()
    }

    pub fn get_enable_parallel(&self) -> bool {
        self.enable_parallel.clone()
    }

    pub fn get_use_simd(&self) -> bool {
        self.use_simd.clone()
    }

    pub fn get_custom_stopwords(&self) -> Option<HashMap<String, Vec<String>>> {
        self.custom_stopwords.clone()
    }

    pub fn get_preserve_patterns(&self) -> Vec<String> {
        self.preserve_patterns.clone()
    }

    pub fn get_target_reduction(&self) -> Option<f32> {
        self.target_reduction.clone()
    }

    pub fn get_enable_semantic_clustering(&self) -> bool {
        self.enable_semantic_clustering.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> TokenReductionConfig {
        kreuzberg::TokenReductionConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PdfAnnotation")]
#[serde(default, rename_all = "camelCase")]
pub struct PdfAnnotation {
    /// The type of annotation.
    #[php(prop, name = "annotationType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub annotation_type: String,
    /// Text content of the annotation (e.g., comment text, link URL).
    #[php(prop, name = "content")]
    pub content: Option<String>,
    /// Page number where the annotation appears (1-indexed).
    #[php(prop, name = "pageNumber")]
    pub page_number: u32,
    /// Bounding box of the annotation on the page.
    pub bounding_box: Option<BoundingBox>,
}

#[php_impl]
impl PdfAnnotation {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(annotationType: String, content: Option<String>, pageNumber: u32) -> Self {
        Self {
            annotation_type: annotationType,
            content,
            page_number: pageNumber,
            bounding_box: Default::default(),
        }
    }

    pub fn get_annotation_type(&self) -> String {
        self.annotation_type.clone()
    }

    pub fn get_content(&self) -> Option<String> {
        self.content.clone()
    }

    pub fn get_page_number(&self) -> u32 {
        self.page_number.clone()
    }

    pub fn get_bounding_box(&self) -> Option<BoundingBox> {
        self.bounding_box.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DjotContent")]
#[serde(default, rename_all = "camelCase")]
pub struct DjotContent {
    /// Plain text representation for backwards compatibility
    #[php(prop, name = "plainText")]
    pub plain_text: String,
    /// Structured block-level content
    pub blocks: Vec<FormattedBlock>,
    /// Metadata from YAML frontmatter
    pub metadata: Metadata,
    /// Extracted tables as structured data
    pub tables: Vec<Table>,
    /// Extracted images with metadata
    pub images: Vec<DjotImage>,
    /// Extracted links with URLs
    pub links: Vec<DjotLink>,
    /// Footnote definitions
    pub footnotes: Vec<Footnote>,
    /// Attributes mapped by element identifier (if present)
    #[php(prop, name = "attributes")]
    #[serde(skip)]
    pub attributes: Vec<String>,
}

#[php_impl]
impl DjotContent {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(plainText: String, attributes: Vec<String>) -> Self {
        Self {
            plain_text: plainText,
            blocks: Default::default(),
            metadata: Default::default(),
            tables: Default::default(),
            images: Default::default(),
            links: Default::default(),
            footnotes: Default::default(),
            attributes,
        }
    }

    pub fn get_plain_text(&self) -> String {
        self.plain_text.clone()
    }

    pub fn get_blocks(&self) -> Vec<FormattedBlock> {
        self.blocks.clone()
    }

    pub fn get_metadata(&self) -> Metadata {
        self.metadata.clone()
    }

    pub fn get_tables(&self) -> Vec<Table> {
        self.tables.clone()
    }

    pub fn get_images(&self) -> Vec<DjotImage> {
        self.images.clone()
    }

    pub fn get_links(&self) -> Vec<DjotLink> {
        self.links.clone()
    }

    pub fn get_footnotes(&self) -> Vec<Footnote> {
        self.footnotes.clone()
    }

    pub fn get_attributes(&self) -> Vec<String> {
        self.attributes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\FormattedBlock")]
#[serde(default, rename_all = "camelCase")]
pub struct FormattedBlock {
    /// Type of block element
    #[php(prop, name = "blockType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub block_type: String,
    /// Heading level (1-6) for headings, or nesting level for lists
    #[php(prop, name = "level")]
    pub level: Option<i64>,
    /// Inline content within the block
    pub inline_content: Vec<InlineElement>,
    /// Element attributes (classes, IDs, key-value pairs)
    #[php(prop, name = "attributes")]
    #[serde(skip)]
    pub attributes: Option<String>,
    /// Language identifier for code blocks
    #[php(prop, name = "language")]
    pub language: Option<String>,
    /// Raw code content for code blocks
    #[php(prop, name = "code")]
    pub code: Option<String>,
    /// Nested blocks for containers (blockquotes, list items, divs)
    pub children: Vec<FormattedBlock>,
}

#[php_impl]
impl FormattedBlock {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        blockType: String,
        level: Option<i64>,
        attributes: Option<String>,
        language: Option<String>,
        code: Option<String>,
    ) -> Self {
        Self {
            block_type: blockType,
            level,
            inline_content: Default::default(),
            attributes,
            language,
            code,
            children: Default::default(),
        }
    }

    pub fn get_block_type(&self) -> String {
        self.block_type.clone()
    }

    pub fn get_level(&self) -> Option<i64> {
        self.level.clone()
    }

    pub fn get_inline_content(&self) -> Vec<InlineElement> {
        self.inline_content.clone()
    }

    pub fn get_attributes(&self) -> Option<String> {
        self.attributes.clone()
    }

    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    pub fn get_code(&self) -> Option<String> {
        self.code.clone()
    }

    pub fn get_children(&self) -> Vec<FormattedBlock> {
        self.children.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\InlineElement")]
#[serde(default, rename_all = "camelCase")]
pub struct InlineElement {
    /// Type of inline element
    #[php(prop, name = "elementType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub element_type: String,
    /// Text content
    #[php(prop, name = "content")]
    pub content: String,
    /// Element attributes
    #[php(prop, name = "attributes")]
    #[serde(skip)]
    pub attributes: Option<String>,
    /// Additional metadata (e.g., href for links, src/alt for images)
    pub metadata: Option<HashMap<String, String>>,
}

#[php_impl]
impl InlineElement {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(elementType: String, content: String, attributes: Option<String>) -> Self {
        Self {
            element_type: elementType,
            content,
            attributes,
            metadata: Default::default(),
        }
    }

    pub fn get_element_type(&self) -> String {
        self.element_type.clone()
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_attributes(&self) -> Option<String> {
        self.attributes.clone()
    }

    pub fn get_metadata(&self) -> Option<HashMap<String, String>> {
        self.metadata.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DjotImage")]
#[serde(default, rename_all = "camelCase")]
pub struct DjotImage {
    /// Image source URL or path
    #[php(prop, name = "src")]
    pub src: String,
    /// Alternative text
    #[php(prop, name = "alt")]
    pub alt: String,
    /// Optional title
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Element attributes
    #[php(prop, name = "attributes")]
    #[serde(skip)]
    pub attributes: Option<String>,
}

#[php_impl]
impl DjotImage {
    #[php(constructor)]
    pub fn new(src: String, alt: String, title: Option<String>, attributes: Option<String>) -> Self {
        Self {
            src,
            alt,
            title,
            attributes,
        }
    }

    pub fn get_src(&self) -> String {
        self.src.clone()
    }

    pub fn get_alt(&self) -> String {
        self.alt.clone()
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_attributes(&self) -> Option<String> {
        self.attributes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DjotLink")]
#[serde(default, rename_all = "camelCase")]
pub struct DjotLink {
    /// Link URL
    #[php(prop, name = "url")]
    pub url: String,
    /// Link text content
    #[php(prop, name = "text")]
    pub text: String,
    /// Optional title
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Element attributes
    #[php(prop, name = "attributes")]
    #[serde(skip)]
    pub attributes: Option<String>,
}

#[php_impl]
impl DjotLink {
    #[php(constructor)]
    pub fn new(url: String, text: String, title: Option<String>, attributes: Option<String>) -> Self {
        Self {
            url,
            text,
            title,
            attributes,
        }
    }

    pub fn get_url(&self) -> String {
        self.url.clone()
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_attributes(&self) -> Option<String> {
        self.attributes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\Footnote")]
#[serde(default, rename_all = "camelCase")]
pub struct Footnote {
    /// Footnote label
    #[php(prop, name = "label")]
    pub label: String,
    /// Footnote content blocks
    pub content: Vec<FormattedBlock>,
}

#[php_impl]
impl Footnote {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(label: String) -> Self {
        Self {
            label,
            content: Default::default(),
        }
    }

    pub fn get_label(&self) -> String {
        self.label.clone()
    }

    pub fn get_content(&self) -> Vec<FormattedBlock> {
        self.content.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\DocumentStructure")]
#[serde(default, rename_all = "camelCase")]
pub struct DocumentStructure {
    /// All nodes in document/reading order.
    pub nodes: Vec<DocumentNode>,
    /// Origin format identifier (e.g. "docx", "pptx", "html", "pdf").
    ///
    /// Allows renderers to apply format-aware heuristics when converting
    /// the document tree to output formats.
    #[php(prop, name = "sourceFormat")]
    pub source_format: Option<String>,
    /// Resolved relationships between nodes (footnote refs, citations, anchor links, etc.).
    ///
    /// Populated during derivation from the internal document representation.
    /// Empty when no relationships are detected.
    pub relationships: Vec<DocumentRelationship>,
    /// Sorted, deduplicated list of node type names present in this document.
    ///
    /// Each value is the snake_case `node_type` tag of the corresponding
    /// [`NodeContent`] variant (e.g. `"paragraph"`, `"heading"`, `"table"`, …).
    ///
    /// Computed from [`nodes`] via [`DocumentStructure::finalize_node_types`].
    /// Empty until that method is called (internal construction paths call it
    /// at the end of derivation).
    #[php(prop, name = "nodeTypes")]
    pub node_types: Vec<String>,
}

impl Default for DocumentStructure {
    fn default() -> Self {
        <kreuzberg::DocumentStructure as Default>::default().into()
    }
}

#[php_impl]
impl DocumentStructure {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(sourceFormat: Option<String>, nodeTypes: Vec<String>) -> Self {
        Self {
            nodes: Default::default(),
            source_format: sourceFormat,
            relationships: Default::default(),
            node_types: nodeTypes,
        }
    }

    pub fn get_nodes(&self) -> Vec<DocumentNode> {
        self.nodes.clone()
    }

    pub fn get_source_format(&self) -> Option<String> {
        self.source_format.clone()
    }

    pub fn get_relationships(&self) -> Vec<DocumentRelationship> {
        self.relationships.clone()
    }

    pub fn get_node_types(&self) -> Vec<String> {
        self.node_types.clone()
    }

    pub fn is_empty(&self) -> bool {
        let core_self = kreuzberg::DocumentStructure {
            nodes: self.nodes.clone().into_iter().map(Into::into).collect(),
            source_format: self.source_format.clone(),
            relationships: self.relationships.clone().into_iter().map(Into::into).collect(),
            node_types: self.node_types.clone(),
        };
        core_self.is_empty()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> DocumentStructure {
        kreuzberg::DocumentStructure::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DocumentRelationship")]
#[serde(default, rename_all = "camelCase")]
pub struct DocumentRelationship {
    /// Source node index (the referencing node).
    #[php(prop, name = "source")]
    pub source: u32,
    /// Target node index (the referenced node).
    #[php(prop, name = "target")]
    pub target: u32,
    /// Semantic kind of the relationship.
    #[php(prop, name = "kind")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub kind: String,
}

#[php_impl]
impl DocumentRelationship {
    #[php(constructor)]
    pub fn new(source: u32, target: u32, kind: String) -> Self {
        Self { source, target, kind }
    }

    pub fn get_source(&self) -> u32 {
        self.source.clone()
    }

    pub fn get_target(&self) -> u32 {
        self.target.clone()
    }

    pub fn get_kind(&self) -> String {
        self.kind.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DocumentNode")]
#[serde(default, rename_all = "camelCase")]
pub struct DocumentNode {
    /// Deterministic identifier (hash of content + position).
    #[php(prop, name = "id")]
    #[serde(skip)]
    pub id: String,
    /// Node content — tagged enum, type-specific data only.
    pub content: NodeContent,
    /// Parent node index (`None` = root-level node).
    #[php(prop, name = "parent")]
    pub parent: Option<u32>,
    /// Child node indices in reading order.
    #[php(prop, name = "children")]
    pub children: Vec<u32>,
    /// Content layer classification.
    #[php(prop, name = "contentLayer")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub content_layer: String,
    /// Page number where this node starts (1-indexed).
    #[php(prop, name = "page")]
    pub page: Option<u32>,
    /// Page number where this node ends (for multi-page tables/sections).
    #[php(prop, name = "pageEnd")]
    pub page_end: Option<u32>,
    /// Bounding box in document coordinates.
    pub bbox: Option<BoundingBox>,
    /// Inline annotations (formatting, links) on this node's text content.
    ///
    /// Only meaningful for text-carrying nodes; empty for containers.
    pub annotations: Vec<TextAnnotation>,
    /// Format-specific key-value attributes.
    ///
    /// Extensible bag for miscellaneous data without a dedicated typed field: CSS classes,
    /// LaTeX environment names, Excel cell formulas, slide layout names, etc.
    pub attributes: Option<HashMap<String, String>>,
}

#[php_impl]
impl DocumentNode {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        id: String,
        parent: Option<u32>,
        children: Vec<u32>,
        contentLayer: String,
        page: Option<u32>,
        pageEnd: Option<u32>,
    ) -> Self {
        Self {
            id,
            content: Default::default(),
            parent,
            children,
            content_layer: contentLayer,
            page,
            page_end: pageEnd,
            bbox: Default::default(),
            annotations: Default::default(),
            attributes: Default::default(),
        }
    }

    pub fn get_id(&self) -> String {
        self.id.clone()
    }

    pub fn get_content(&self) -> NodeContent {
        self.content.clone()
    }

    pub fn get_parent(&self) -> Option<u32> {
        self.parent.clone()
    }

    pub fn get_children(&self) -> Vec<u32> {
        self.children.clone()
    }

    pub fn get_content_layer(&self) -> String {
        self.content_layer.clone()
    }

    pub fn get_page(&self) -> Option<u32> {
        self.page.clone()
    }

    pub fn get_page_end(&self) -> Option<u32> {
        self.page_end.clone()
    }

    pub fn get_bbox(&self) -> Option<BoundingBox> {
        self.bbox.clone().map(Into::into)
    }

    pub fn get_annotations(&self) -> Vec<TextAnnotation> {
        self.annotations.clone()
    }

    pub fn get_attributes(&self) -> Option<HashMap<String, String>> {
        self.attributes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TableGrid")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct TableGrid {
    /// Number of rows in the table.
    #[php(prop, name = "rows")]
    pub rows: u32,
    /// Number of columns in the table.
    #[php(prop, name = "cols")]
    pub cols: u32,
    /// All cells in row-major order.
    pub cells: Vec<GridCell>,
}

impl Default for TableGrid {
    fn default() -> Self {
        <kreuzberg::TableGrid as Default>::default().into()
    }
}

#[php_impl]
impl TableGrid {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(rows: u32, cols: u32) -> Self {
        Self {
            rows,
            cols,
            cells: Default::default(),
        }
    }

    pub fn get_rows(&self) -> u32 {
        self.rows.clone()
    }

    pub fn get_cols(&self) -> u32 {
        self.cols.clone()
    }

    pub fn get_cells(&self) -> Vec<GridCell> {
        self.cells.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\GridCell")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct GridCell {
    /// Cell text content.
    #[php(prop, name = "content")]
    pub content: String,
    /// Zero-indexed row position.
    #[php(prop, name = "row")]
    pub row: u32,
    /// Zero-indexed column position.
    #[php(prop, name = "col")]
    pub col: u32,
    /// Number of rows this cell spans.
    #[php(prop, name = "rowSpan")]
    pub row_span: u32,
    /// Number of columns this cell spans.
    #[php(prop, name = "colSpan")]
    pub col_span: u32,
    /// Whether this is a header cell.
    #[php(prop, name = "isHeader")]
    pub is_header: bool,
    /// Bounding box for this cell (if available).
    pub bbox: Option<BoundingBox>,
}

#[php_impl]
impl GridCell {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: String, row: u32, col: u32, rowSpan: u32, colSpan: u32, isHeader: bool) -> Self {
        Self {
            content,
            row,
            col,
            row_span: rowSpan,
            col_span: colSpan,
            is_header: isHeader,
            bbox: Default::default(),
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_row(&self) -> u32 {
        self.row.clone()
    }

    pub fn get_col(&self) -> u32 {
        self.col.clone()
    }

    pub fn get_row_span(&self) -> u32 {
        self.row_span.clone()
    }

    pub fn get_col_span(&self) -> u32 {
        self.col_span.clone()
    }

    pub fn get_is_header(&self) -> bool {
        self.is_header.clone()
    }

    pub fn get_bbox(&self) -> Option<BoundingBox> {
        self.bbox.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\TextAnnotation")]
#[serde(default, rename_all = "camelCase")]
pub struct TextAnnotation {
    /// Start byte offset in the node's text content (inclusive).
    #[php(prop, name = "start")]
    pub start: u32,
    /// End byte offset in the node's text content (exclusive).
    #[php(prop, name = "end")]
    pub end: u32,
    /// Annotation type.
    pub kind: AnnotationKind,
}

#[php_impl]
impl TextAnnotation {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(start: u32, end: u32) -> Self {
        Self {
            start,
            end,
            kind: Default::default(),
        }
    }

    pub fn get_start(&self) -> u32 {
        self.start.clone()
    }

    pub fn get_end(&self) -> u32 {
        self.end.clone()
    }

    pub fn get_kind(&self) -> AnnotationKind {
        self.kind.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ExtractionResult")]
#[serde(default, rename_all = "camelCase")]
pub struct ExtractionResult {
    #[php(prop, name = "content")]
    pub content: String,
    #[php(prop, name = "mimeType")]
    pub mime_type: String,
    pub metadata: Metadata,
    /// Extraction strategy used to produce the returned text.
    ///
    /// Populated when the extractor can reliably distinguish native text extraction,
    /// OCR-only extraction, or mixed native/OCR output.
    #[php(prop, name = "extractionMethod")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub extraction_method: Option<String>,
    pub tables: Vec<Table>,
    #[php(prop, name = "detectedLanguages")]
    pub detected_languages: Option<Vec<String>>,
    /// Text chunks when chunking is enabled.
    ///
    /// When chunking configuration is provided, the content is split into
    /// overlapping chunks for efficient processing. Each chunk contains the text,
    /// optional embeddings (if enabled), and metadata about its position.
    pub chunks: Option<Vec<Chunk>>,
    /// Extracted images from the document.
    ///
    /// When image extraction is enabled via `ImageExtractionConfig`, this field
    /// contains all images found in the document with their raw data and metadata.
    /// Each image may optionally contain a nested `ocr_result` if OCR was performed.
    pub images: Option<Vec<ExtractedImage>>,
    /// Per-page content when page extraction is enabled.
    ///
    /// When page extraction is configured, the document is split into per-page content
    /// with tables and images mapped to their respective pages.
    pub pages: Option<Vec<PageContent>>,
    /// Semantic elements when element-based result format is enabled.
    ///
    /// When result_format is set to ElementBased, this field contains semantic
    /// elements with type classification, unique identifiers, and metadata for
    /// Unstructured-compatible element-based processing.
    pub elements: Option<Vec<Element>>,
    /// Rich Djot content structure (when extracting Djot documents).
    ///
    /// When extracting Djot documents with structured extraction enabled,
    /// this field contains the full semantic structure including:
    /// - Block-level elements with nesting
    /// - Inline formatting with attributes
    /// - Links, images, footnotes
    /// - Math expressions
    /// - Complete attribute information
    ///
    /// The `content` field still contains plain text for backward compatibility.
    ///
    /// Always `None` for non-Djot documents.
    pub djot_content: Option<DjotContent>,
    /// OCR elements with full spatial and confidence metadata.
    ///
    /// When OCR is performed with element extraction enabled, this field contains
    /// the structured representation of detected text including:
    /// - Bounding geometry (rectangles or quadrilaterals)
    /// - Confidence scores (detection and recognition)
    /// - Rotation information
    /// - Hierarchical relationships (Tesseract only)
    ///
    /// This field preserves all metadata that would otherwise be lost when
    /// converting to plain text or markdown output formats.
    ///
    /// Only populated when `OcrElementConfig.include_elements` is true.
    pub ocr_elements: Option<Vec<OcrElement>>,
    /// Structured document tree (when document structure extraction is enabled).
    ///
    /// When `include_document_structure` is true in `ExtractionConfig`, this field
    /// contains the full hierarchical representation of the document including:
    /// - Heading-driven section nesting
    /// - Table grids with cell-level metadata
    /// - Content layer classification (body, header, footer, footnote)
    /// - Inline text annotations (formatting, links)
    /// - Bounding boxes and page numbers
    ///
    /// Independent of `result_format` — can be combined with Unified or ElementBased.
    pub document: Option<DocumentStructure>,
    /// Extracted keywords when keyword extraction is enabled.
    ///
    /// When keyword extraction (RAKE or YAKE) is configured, this field contains
    /// the extracted keywords with scores, algorithm info, and position data.
    /// Previously stored in `metadata.additional["keywords"]`.
    pub extracted_keywords: Option<Vec<Keyword>>,
    /// Document quality score from quality analysis.
    ///
    /// A value between 0.0 and 1.0 indicating the overall text quality.
    /// Previously stored in `metadata.additional["quality_score"]`.
    #[php(prop, name = "qualityScore")]
    pub quality_score: Option<f64>,
    /// Non-fatal warnings collected during processing pipeline stages.
    ///
    /// Captures errors from optional pipeline features (embedding, chunking,
    /// language detection, output formatting) that don't prevent extraction
    /// but may indicate degraded results.
    /// Previously stored as individual keys in `metadata.additional`.
    pub processing_warnings: Vec<ProcessingWarning>,
    /// PDF annotations extracted from the document.
    ///
    /// When annotation extraction is enabled via `PdfConfig::extract_annotations`,
    /// this field contains text notes, highlights, links, stamps, and other
    /// annotations found in PDF documents.
    pub annotations: Option<Vec<PdfAnnotation>>,
    /// Nested extraction results from archive contents.
    ///
    /// When extracting archives, each processable file inside produces its own
    /// full extraction result. Set to `None` for non-archive formats.
    /// Use `max_archive_depth` in config to control recursion depth.
    pub children: Option<Vec<ArchiveEntry>>,
    /// URIs/links discovered during document extraction.
    ///
    /// Contains hyperlinks, image references, citations, email addresses, and
    /// other URI-like references found in the document. Always extracted when
    /// present in the source document.
    pub uris: Option<Vec<ExtractedUri>>,
    /// Tracked changes embedded in the source document.
    ///
    /// Populated by per-format extractors that understand change-tracking
    /// metadata (DOCX `w:ins`/`w:del`/`w:rPrChange`, ODT `text:change-*`,
    /// …). Every extractor defaults to `None` until its format-specific
    /// implementation is added. Extractors that do populate this field follow
    /// the "accepted-changes" convention: inserted text is present in
    /// `content`, deleted text is absent — the revision list is the separate
    /// audit trail.
    pub revisions: Option<Vec<DocumentRevision>>,
    /// Structured extraction output from LLM-based JSON schema extraction.
    ///
    /// When `structured_extraction` is configured in `ExtractionConfig`, the
    /// extracted document content is sent to a VLM with the provided JSON schema.
    /// The response is parsed and stored here as a JSON value matching the schema.
    pub structured_output: Option<serde_json::Value>,
    /// Code intelligence results from tree-sitter analysis.
    ///
    /// Populated when extracting source code files with the `tree-sitter` feature.
    /// Contains metrics, structural analysis, imports/exports, comments,
    /// docstrings, symbols, diagnostics, and optionally chunked code segments.
    ///
    /// Stored as an opaque JSON value so that all language bindings (Go, Java,
    /// C#, …) can deserialize it as a raw JSON object rather than a typed struct.
    /// The underlying type is `tree_sitter_language_pack::ProcessResult`.
    pub code_intelligence: Option<serde_json::Value>,
    /// LLM token usage and cost data for all LLM calls made during this extraction.
    ///
    /// Contains one entry per LLM call. Multiple entries are produced when
    /// VLM OCR, structured extraction, or LLM embeddings run during
    /// the same extraction.
    ///
    /// `None` when no LLM was used.
    pub llm_usage: Option<Vec<LlmUsage>>,
    /// Pre-rendered content in the requested output format.
    ///
    /// Populated during `derive_extraction_result` before tree derivation consumes
    /// element data. `apply_output_format` swaps this into `content` at the end
    /// of the pipeline, after post-processors have operated on plain text.
    #[php(prop, name = "formattedContent")]
    pub formatted_content: Option<String>,
    /// Structured hOCR document for the OCR+layout pipeline.
    ///
    /// When tesseract produces hOCR output, the parsed `InternalDocument` carries
    /// paragraph structure with bounding boxes and confidence scores. The layout
    /// classification step enriches these elements before final rendering.
    #[php(prop, name = "ocrInternalDocument")]
    #[serde(skip)]
    pub ocr_internal_document: Option<String>,
}

impl Default for ExtractionResult {
    fn default() -> Self {
        <kreuzberg::ExtractionResult as Default>::default().into()
    }
}

#[php_impl]
impl ExtractionResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        content: String,
        mimeType: String,
        extractionMethod: Option<String>,
        detectedLanguages: Option<Vec<String>>,
        qualityScore: Option<f64>,
        formattedContent: Option<String>,
        ocrInternalDocument: Option<String>,
    ) -> Self {
        Self {
            content,
            mime_type: mimeType,
            metadata: Default::default(),
            extraction_method: extractionMethod,
            tables: Default::default(),
            detected_languages: detectedLanguages,
            chunks: Default::default(),
            images: Default::default(),
            pages: Default::default(),
            elements: Default::default(),
            djot_content: Default::default(),
            ocr_elements: Default::default(),
            document: Default::default(),
            extracted_keywords: Default::default(),
            quality_score: qualityScore,
            processing_warnings: Default::default(),
            annotations: Default::default(),
            children: Default::default(),
            uris: Default::default(),
            revisions: Default::default(),
            structured_output: Default::default(),
            code_intelligence: Default::default(),
            llm_usage: Default::default(),
            formatted_content: formattedContent,
            ocr_internal_document: ocrInternalDocument,
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_mime_type(&self) -> String {
        self.mime_type.clone()
    }

    pub fn get_metadata(&self) -> Metadata {
        self.metadata.clone()
    }

    pub fn get_extraction_method(&self) -> Option<String> {
        self.extraction_method.clone().map(Into::into)
    }

    pub fn get_tables(&self) -> Vec<Table> {
        self.tables.clone()
    }

    pub fn get_detected_languages(&self) -> Option<Vec<String>> {
        self.detected_languages.clone()
    }

    pub fn get_chunks(&self) -> Option<Vec<Chunk>> {
        self.chunks.clone()
    }

    pub fn get_images(&self) -> Option<Vec<ExtractedImage>> {
        self.images.clone()
    }

    pub fn get_pages(&self) -> Option<Vec<PageContent>> {
        self.pages.clone()
    }

    pub fn get_elements(&self) -> Option<Vec<Element>> {
        self.elements.clone()
    }

    pub fn get_djot_content(&self) -> Option<DjotContent> {
        self.djot_content.clone().map(Into::into)
    }

    pub fn get_ocr_elements(&self) -> Option<Vec<OcrElement>> {
        self.ocr_elements.clone()
    }

    pub fn get_document(&self) -> Option<DocumentStructure> {
        self.document.clone().map(Into::into)
    }

    pub fn get_extracted_keywords(&self) -> Option<Vec<Keyword>> {
        self.extracted_keywords.clone()
    }

    pub fn get_quality_score(&self) -> Option<f64> {
        self.quality_score.clone()
    }

    pub fn get_processing_warnings(&self) -> Vec<ProcessingWarning> {
        self.processing_warnings.clone()
    }

    pub fn get_annotations(&self) -> Option<Vec<PdfAnnotation>> {
        self.annotations.clone()
    }

    pub fn get_children(&self) -> Option<Vec<ArchiveEntry>> {
        self.children.clone()
    }

    pub fn get_uris(&self) -> Option<Vec<ExtractedUri>> {
        self.uris.clone()
    }

    pub fn get_revisions(&self) -> Option<Vec<DocumentRevision>> {
        self.revisions.clone()
    }

    pub fn get_structured_output(&self) -> Option<String> {
        self.structured_output
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }

    pub fn get_code_intelligence(&self) -> Option<String> {
        self.code_intelligence
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }

    pub fn get_llm_usage(&self) -> Option<Vec<LlmUsage>> {
        self.llm_usage.clone()
    }

    pub fn get_formatted_content(&self) -> Option<String> {
        self.formatted_content.clone()
    }

    pub fn get_ocr_internal_document(&self) -> Option<String> {
        self.ocr_internal_document.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ArchiveEntry")]
#[serde(default, rename_all = "camelCase")]
pub struct ArchiveEntry {
    /// Archive-relative file path (e.g. "folder/document.pdf").
    #[php(prop, name = "path")]
    pub path: String,
    /// Detected MIME type of the file.
    #[php(prop, name = "mimeType")]
    pub mime_type: String,
    /// Full extraction result for this file.
    pub result: ExtractionResult,
}

#[php_impl]
impl ArchiveEntry {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(path: String, mimeType: String) -> Self {
        Self {
            path,
            mime_type: mimeType,
            result: Default::default(),
        }
    }

    pub fn get_path(&self) -> String {
        self.path.clone()
    }

    pub fn get_mime_type(&self) -> String {
        self.mime_type.clone()
    }

    pub fn get_result(&self) -> ExtractionResult {
        self.result.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ProcessingWarning")]
#[serde(default, rename_all = "camelCase")]
pub struct ProcessingWarning {
    /// The pipeline stage or feature that produced this warning
    /// (e.g., "embedding", "chunking", "language_detection", "output_format").
    #[php(prop, name = "source")]
    pub source: String,
    /// Human-readable description of what went wrong.
    #[php(prop, name = "message")]
    pub message: String,
}

#[php_impl]
impl ProcessingWarning {
    #[php(constructor)]
    pub fn new(source: String, message: String) -> Self {
        Self { source, message }
    }

    pub fn get_source(&self) -> String {
        self.source.clone()
    }

    pub fn get_message(&self) -> String {
        self.message.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\LlmUsage")]
#[serde(default, rename_all = "camelCase")]
pub struct LlmUsage {
    /// The LLM model identifier (e.g. "openai/gpt-4o", "anthropic/claude-sonnet-4-20250514").
    #[php(prop, name = "model")]
    pub model: String,
    /// The pipeline stage that triggered this LLM call
    /// (e.g. "vlm_ocr", "structured_extraction", "embeddings").
    #[php(prop, name = "source")]
    pub source: String,
    /// Number of input/prompt tokens consumed.
    #[php(prop, name = "inputTokens")]
    pub input_tokens: Option<i64>,
    /// Number of output/completion tokens generated.
    #[php(prop, name = "outputTokens")]
    pub output_tokens: Option<i64>,
    /// Total tokens (input + output).
    #[php(prop, name = "totalTokens")]
    pub total_tokens: Option<i64>,
    /// Estimated cost in USD based on the provider's published pricing.
    #[php(prop, name = "estimatedCost")]
    pub estimated_cost: Option<f64>,
    /// Why the model stopped generating (e.g. "stop", "length", "content_filter").
    #[php(prop, name = "finishReason")]
    pub finish_reason: Option<String>,
}

impl Default for LlmUsage {
    fn default() -> Self {
        <kreuzberg::LlmUsage as Default>::default().into()
    }
}

#[php_impl]
impl LlmUsage {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        model: String,
        source: String,
        inputTokens: Option<i64>,
        outputTokens: Option<i64>,
        totalTokens: Option<i64>,
        estimatedCost: Option<f64>,
        finishReason: Option<String>,
    ) -> Self {
        Self {
            model,
            source,
            input_tokens: inputTokens,
            output_tokens: outputTokens,
            total_tokens: totalTokens,
            estimated_cost: estimatedCost,
            finish_reason: finishReason,
        }
    }

    pub fn get_model(&self) -> String {
        self.model.clone()
    }

    pub fn get_source(&self) -> String {
        self.source.clone()
    }

    pub fn get_input_tokens(&self) -> Option<i64> {
        self.input_tokens.clone()
    }

    pub fn get_output_tokens(&self) -> Option<i64> {
        self.output_tokens.clone()
    }

    pub fn get_total_tokens(&self) -> Option<i64> {
        self.total_tokens.clone()
    }

    pub fn get_estimated_cost(&self) -> Option<f64> {
        self.estimated_cost.clone()
    }

    pub fn get_finish_reason(&self) -> Option<String> {
        self.finish_reason.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\Chunk")]
#[serde(default, rename_all = "camelCase")]
pub struct Chunk {
    /// The text content of this chunk.
    #[php(prop, name = "content")]
    pub content: String,
    /// Semantic structural classification of this chunk.
    ///
    /// Assigned by the heuristic classifier based on content patterns and
    /// heading context. Defaults to `ChunkType::Unknown` when no rule matches.
    #[php(prop, name = "chunkType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub chunk_type: String,
    /// Optional embedding vector for this chunk.
    ///
    /// Only populated when `EmbeddingConfig` is provided in chunking configuration.
    /// The dimensionality depends on the chosen embedding model.
    #[php(prop, name = "embedding")]
    pub embedding: Option<Vec<f32>>,
    /// Metadata about this chunk's position and properties.
    pub metadata: ChunkMetadata,
}

#[php_impl]
impl Chunk {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: String, chunkType: String, embedding: Option<Vec<f32>>) -> Self {
        Self {
            content,
            chunk_type: chunkType,
            embedding,
            metadata: Default::default(),
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_chunk_type(&self) -> String {
        self.chunk_type.clone()
    }

    pub fn get_embedding(&self) -> Option<Vec<f32>> {
        self.embedding.clone()
    }

    pub fn get_metadata(&self) -> ChunkMetadata {
        self.metadata.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\HeadingContext")]
#[serde(default, rename_all = "camelCase")]
pub struct HeadingContext {
    /// The heading hierarchy from document root to this chunk's section.
    /// Index 0 is the outermost (h1), last element is the most specific.
    pub headings: Vec<HeadingLevel>,
}

#[php_impl]
impl HeadingContext {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_headings(&self) -> Vec<HeadingLevel> {
        self.headings.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\HeadingLevel")]
#[serde(default, rename_all = "camelCase")]
pub struct HeadingLevel {
    /// Heading depth (1 = h1, 2 = h2, etc.)
    #[php(prop, name = "level")]
    pub level: u8,
    /// The text content of the heading.
    #[php(prop, name = "text")]
    pub text: String,
}

#[php_impl]
impl HeadingLevel {
    #[php(constructor)]
    pub fn new(level: u8, text: String) -> Self {
        Self { level, text }
    }

    pub fn get_level(&self) -> u8 {
        self.level.clone()
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ChunkMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ChunkMetadata {
    /// Byte offset where this chunk starts in the original text (UTF-8 valid boundary).
    #[php(prop, name = "byteStart")]
    pub byte_start: i64,
    /// Byte offset where this chunk ends in the original text (UTF-8 valid boundary).
    #[php(prop, name = "byteEnd")]
    pub byte_end: i64,
    /// Number of tokens in this chunk (if available).
    ///
    /// This is calculated by the embedding model's tokenizer if embeddings are enabled.
    #[php(prop, name = "tokenCount")]
    pub token_count: Option<i64>,
    /// Zero-based index of this chunk in the document.
    #[php(prop, name = "chunkIndex")]
    pub chunk_index: i64,
    /// Total number of chunks in the document.
    #[php(prop, name = "totalChunks")]
    pub total_chunks: i64,
    /// First page number this chunk spans (1-indexed).
    ///
    /// Only populated when page tracking is enabled in extraction configuration.
    #[php(prop, name = "firstPage")]
    pub first_page: Option<u32>,
    /// Last page number this chunk spans (1-indexed, equal to first_page for single-page chunks).
    ///
    /// Only populated when page tracking is enabled in extraction configuration.
    #[php(prop, name = "lastPage")]
    pub last_page: Option<u32>,
    /// Heading context when using Markdown chunker.
    ///
    /// Contains the heading hierarchy this chunk falls under.
    /// Only populated when `ChunkerType::Markdown` is used.
    pub heading_context: Option<HeadingContext>,
    /// Indices into `ExtractionResult.images` for images on pages covered by this chunk.
    ///
    /// Contains zero-based indices into the top-level `images` collection for every
    /// image whose `page_number` falls within `[first_page, last_page]`.
    /// Empty when image extraction is disabled or the chunk spans no pages with images.
    #[php(prop, name = "imageIndices")]
    pub image_indices: Vec<u32>,
}

#[php_impl]
impl ChunkMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        byteStart: i64,
        byteEnd: i64,
        tokenCount: Option<i64>,
        chunkIndex: i64,
        totalChunks: i64,
        firstPage: Option<u32>,
        lastPage: Option<u32>,
        imageIndices: Vec<u32>,
    ) -> Self {
        Self {
            byte_start: byteStart,
            byte_end: byteEnd,
            token_count: tokenCount,
            chunk_index: chunkIndex,
            total_chunks: totalChunks,
            first_page: firstPage,
            last_page: lastPage,
            heading_context: Default::default(),
            image_indices: imageIndices,
        }
    }

    pub fn get_byte_start(&self) -> i64 {
        self.byte_start.clone()
    }

    pub fn get_byte_end(&self) -> i64 {
        self.byte_end.clone()
    }

    pub fn get_token_count(&self) -> Option<i64> {
        self.token_count.clone()
    }

    pub fn get_chunk_index(&self) -> i64 {
        self.chunk_index.clone()
    }

    pub fn get_total_chunks(&self) -> i64 {
        self.total_chunks.clone()
    }

    pub fn get_first_page(&self) -> Option<u32> {
        self.first_page.clone()
    }

    pub fn get_last_page(&self) -> Option<u32> {
        self.last_page.clone()
    }

    pub fn get_heading_context(&self) -> Option<HeadingContext> {
        self.heading_context.clone().map(Into::into)
    }

    pub fn get_image_indices(&self) -> Vec<u32> {
        self.image_indices.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ExtractedImage")]
#[serde(default, rename_all = "camelCase")]
pub struct ExtractedImage {
    /// Raw image data (PNG, JPEG, WebP, etc. bytes).
    /// Uses `bytes::Bytes` for cheap cloning of large buffers.
    pub data: Vec<u8>,
    /// Image format (e.g., "jpeg", "png", "webp")
    /// Uses Cow<'static, str> to avoid allocation for static literals.
    #[php(prop, name = "format")]
    pub format: String,
    /// Zero-indexed position of this image in the document/page
    #[php(prop, name = "imageIndex")]
    pub image_index: u32,
    /// Page/slide number where image was found (1-indexed)
    #[php(prop, name = "pageNumber")]
    pub page_number: Option<u32>,
    /// Image width in pixels
    #[php(prop, name = "width")]
    pub width: Option<u32>,
    /// Image height in pixels
    #[php(prop, name = "height")]
    pub height: Option<u32>,
    /// Colorspace information (e.g., "RGB", "CMYK", "Gray")
    #[php(prop, name = "colorspace")]
    pub colorspace: Option<String>,
    /// Bits per color component (e.g., 8, 16)
    #[php(prop, name = "bitsPerComponent")]
    pub bits_per_component: Option<u32>,
    /// Whether this image is a mask image
    #[php(prop, name = "isMask")]
    pub is_mask: bool,
    /// Optional description of the image
    #[php(prop, name = "description")]
    pub description: Option<String>,
    /// Nested OCR extraction result (if image was OCRed)
    ///
    /// When OCR is performed on this image, the result is embedded here
    /// rather than in a separate collection, making the relationship explicit.
    pub ocr_result: Option<ExtractionResult>,
    /// Bounding box of the image on the page (PDF coordinates: x0=left, y0=bottom, x1=right, y1=top).
    /// Only populated for PDF-extracted images when position data is available from the PDF extractor.
    pub bounding_box: Option<BoundingBox>,
    /// Original source path of the image within the document archive (e.g., "media/image1.png" in DOCX).
    /// Used for rendering image references when the binary data is not extracted.
    #[php(prop, name = "sourcePath")]
    pub source_path: Option<String>,
    /// Heuristic classification of what this image likely depicts.
    /// `None` if classification was disabled or inconclusive.
    #[php(prop, name = "imageKind")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_kind: Option<String>,
    /// Confidence score for `image_kind`, in the range 0.0 to 1.0.
    #[php(prop, name = "kindConfidence")]
    pub kind_confidence: Option<f32>,
    /// Identifier shared across images that form a single logical figure
    /// (e.g. all raster tiles of one technical drawing). `None` for singletons.
    #[php(prop, name = "clusterId")]
    pub cluster_id: Option<u32>,
}

#[php_impl]
impl ExtractedImage {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        data: PhpBytes,
        format: String,
        imageIndex: u32,
        pageNumber: Option<u32>,
        width: Option<u32>,
        height: Option<u32>,
        colorspace: Option<String>,
        bitsPerComponent: Option<u32>,
        isMask: bool,
        description: Option<String>,
        sourcePath: Option<String>,
        imageKind: Option<String>,
        kindConfidence: Option<f32>,
        clusterId: Option<u32>,
    ) -> Self {
        Self {
            data: data.0,
            format,
            image_index: imageIndex,
            page_number: pageNumber,
            width,
            height,
            colorspace,
            bits_per_component: bitsPerComponent,
            is_mask: isMask,
            description,
            ocr_result: Default::default(),
            bounding_box: Default::default(),
            source_path: sourcePath,
            image_kind: imageKind,
            kind_confidence: kindConfidence,
            cluster_id: clusterId,
        }
    }

    pub fn get_data(&self) -> Vec<u8> {
        self.data.clone()
    }

    pub fn get_format(&self) -> String {
        self.format.clone()
    }

    pub fn get_image_index(&self) -> u32 {
        self.image_index.clone()
    }

    pub fn get_page_number(&self) -> Option<u32> {
        self.page_number.clone()
    }

    pub fn get_width(&self) -> Option<u32> {
        self.width.clone()
    }

    pub fn get_height(&self) -> Option<u32> {
        self.height.clone()
    }

    pub fn get_colorspace(&self) -> Option<String> {
        self.colorspace.clone()
    }

    pub fn get_bits_per_component(&self) -> Option<u32> {
        self.bits_per_component.clone()
    }

    pub fn get_is_mask(&self) -> bool {
        self.is_mask.clone()
    }

    pub fn get_description(&self) -> Option<String> {
        self.description.clone()
    }

    pub fn get_ocr_result(&self) -> Option<ExtractionResult> {
        self.ocr_result.clone().map(Into::into)
    }

    pub fn get_bounding_box(&self) -> Option<BoundingBox> {
        self.bounding_box.clone().map(Into::into)
    }

    pub fn get_source_path(&self) -> Option<String> {
        self.source_path.clone()
    }

    pub fn get_image_kind(&self) -> Option<String> {
        self.image_kind.clone().map(Into::into)
    }

    pub fn get_kind_confidence(&self) -> Option<f32> {
        self.kind_confidence.clone()
    }

    pub fn get_cluster_id(&self) -> Option<u32> {
        self.cluster_id.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\BoundingBox")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct BoundingBox {
    /// Left x-coordinate
    #[php(prop, name = "x0")]
    pub x0: f64,
    /// Bottom y-coordinate
    #[php(prop, name = "y0")]
    pub y0: f64,
    /// Right x-coordinate
    #[php(prop, name = "x1")]
    pub x1: f64,
    /// Top y-coordinate
    #[php(prop, name = "y1")]
    pub y1: f64,
}

impl Default for BoundingBox {
    fn default() -> Self {
        <kreuzberg::BoundingBox as Default>::default().into()
    }
}

#[php_impl]
impl BoundingBox {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self {
        Self { x0, y0, x1, y1 }
    }

    pub fn get_x0(&self) -> f64 {
        self.x0.clone()
    }

    pub fn get_y0(&self) -> f64 {
        self.y0.clone()
    }

    pub fn get_x1(&self) -> f64 {
        self.x1.clone()
    }

    pub fn get_y1(&self) -> f64 {
        self.y1.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ElementMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ElementMetadata {
    /// Page number (1-indexed)
    #[php(prop, name = "pageNumber")]
    pub page_number: Option<u32>,
    /// Source filename or document name
    #[php(prop, name = "filename")]
    pub filename: Option<String>,
    /// Bounding box coordinates if available
    pub coordinates: Option<BoundingBox>,
    /// Position index in the element sequence
    #[php(prop, name = "elementIndex")]
    pub element_index: Option<i64>,
    /// Additional custom metadata
    pub additional: HashMap<String, String>,
}

#[php_impl]
impl ElementMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_page_number(&self) -> Option<u32> {
        self.page_number.clone()
    }

    pub fn get_filename(&self) -> Option<String> {
        self.filename.clone()
    }

    pub fn get_coordinates(&self) -> Option<BoundingBox> {
        self.coordinates.clone().map(Into::into)
    }

    pub fn get_element_index(&self) -> Option<i64> {
        self.element_index.clone()
    }

    pub fn get_additional(&self) -> HashMap<String, String> {
        self.additional.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\Element")]
#[serde(default, rename_all = "camelCase")]
pub struct Element {
    /// Unique element identifier
    #[php(prop, name = "elementId")]
    #[serde(skip)]
    pub element_id: String,
    /// Semantic type of this element
    #[php(prop, name = "elementType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub element_type: String,
    /// Text content of the element
    #[php(prop, name = "text")]
    pub text: String,
    /// Metadata about the element
    pub metadata: ElementMetadata,
}

#[php_impl]
impl Element {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(elementId: String, elementType: String, text: String) -> Self {
        Self {
            element_id: elementId,
            element_type: elementType,
            text,
            metadata: Default::default(),
        }
    }

    pub fn get_element_id(&self) -> String {
        self.element_id.clone()
    }

    pub fn get_element_type(&self) -> String {
        self.element_type.clone()
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_metadata(&self) -> ElementMetadata {
        self.metadata.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ExcelWorkbook")]
#[serde(default, rename_all = "camelCase")]
pub struct ExcelWorkbook {
    /// All sheets in the workbook
    pub sheets: Vec<ExcelSheet>,
    /// Workbook-level metadata (author, creation date, etc.)
    pub metadata: HashMap<String, String>,
    /// Collaborative-edit revision headers from `xl/revisions/revisionHeaders.xml`.
    ///
    /// Populated for legacy shared-workbook `.xlsx` files that contain the
    /// `xl/revisions/` directory. Each `<header>` element maps to one
    /// `DocumentRevision { kind: FormatChange }` carrying the header's `guid`
    /// (→ `revision_id`), `userName` (→ `author`), and `dateTime` (→ `timestamp`).
    /// `anchor` and `delta` are `None`/empty for v1 (per-cell log parsing is a
    /// follow-up). `None` when `xl/revisions/revisionHeaders.xml` is absent.
    pub revisions: Option<Vec<DocumentRevision>>,
}

#[php_impl]
impl ExcelWorkbook {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_sheets(&self) -> Vec<ExcelSheet> {
        self.sheets.clone()
    }

    pub fn get_metadata(&self) -> HashMap<String, String> {
        self.metadata.clone()
    }

    pub fn get_revisions(&self) -> Option<Vec<DocumentRevision>> {
        self.revisions.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ExcelSheet")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct ExcelSheet {
    /// Sheet name as it appears in Excel
    #[php(prop, name = "name")]
    pub name: String,
    /// Sheet content converted to Markdown tables
    #[php(prop, name = "markdown")]
    pub markdown: String,
    /// Number of rows
    #[php(prop, name = "rowCount")]
    pub row_count: i64,
    /// Number of columns
    #[php(prop, name = "colCount")]
    pub col_count: i64,
    /// Total number of non-empty cells
    #[php(prop, name = "cellCount")]
    pub cell_count: i64,
    /// Pre-extracted table cells (2D vector of cell values)
    /// Populated during markdown generation to avoid re-parsing markdown.
    /// None for empty sheets.
    pub table_cells: Option<Vec<Vec<String>>>,
}

#[php_impl]
impl ExcelSheet {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        name: String,
        markdown: String,
        rowCount: i64,
        colCount: i64,
        cellCount: i64,
        tableCells: Option<Vec<Vec<String>>>,
    ) -> Self {
        Self {
            name,
            markdown,
            row_count: rowCount,
            col_count: colCount,
            cell_count: cellCount,
            table_cells: tableCells,
        }
    }

    pub fn get_name(&self) -> String {
        self.name.clone()
    }

    pub fn get_markdown(&self) -> String {
        self.markdown.clone()
    }

    pub fn get_row_count(&self) -> i64 {
        self.row_count.clone()
    }

    pub fn get_col_count(&self) -> i64 {
        self.col_count.clone()
    }

    pub fn get_cell_count(&self) -> i64 {
        self.cell_count.clone()
    }

    pub fn get_table_cells(&self) -> Option<Vec<Vec<String>>> {
        self.table_cells.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\XmlExtractionResult")]
#[serde(default, rename_all = "camelCase")]
pub struct XmlExtractionResult {
    /// Extracted text content (XML structure filtered out)
    #[php(prop, name = "content")]
    pub content: String,
    /// Total number of XML elements processed
    #[php(prop, name = "elementCount")]
    pub element_count: i64,
    /// List of unique element names found (sorted)
    #[php(prop, name = "uniqueElements")]
    pub unique_elements: Vec<String>,
}

#[php_impl]
impl XmlExtractionResult {
    #[php(constructor)]
    pub fn new(content: String, elementCount: i64, uniqueElements: Vec<String>) -> Self {
        Self {
            content,
            element_count: elementCount,
            unique_elements: uniqueElements,
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_element_count(&self) -> i64 {
        self.element_count.clone()
    }

    pub fn get_unique_elements(&self) -> Vec<String> {
        self.unique_elements.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\TextExtractionResult")]
#[serde(default, rename_all = "camelCase")]
pub struct TextExtractionResult {
    /// Extracted text content
    #[php(prop, name = "content")]
    pub content: String,
    /// Number of lines
    #[php(prop, name = "lineCount")]
    pub line_count: i64,
    /// Number of words
    #[php(prop, name = "wordCount")]
    pub word_count: i64,
    /// Number of characters
    #[php(prop, name = "characterCount")]
    pub character_count: i64,
    /// Markdown headers (text only, Markdown files only)
    #[php(prop, name = "headers")]
    pub headers: Option<Vec<String>>,
    /// Markdown links as (text, URL) tuples (Markdown files only)
    #[serde(skip)]
    pub links: Option<Vec<Vec<String>>>,
    /// Code blocks as (language, code) tuples (Markdown files only)
    #[serde(skip)]
    pub code_blocks: Option<Vec<Vec<String>>>,
}

#[php_impl]
impl TextExtractionResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        content: String,
        lineCount: i64,
        wordCount: i64,
        characterCount: i64,
        headers: Option<Vec<String>>,
        links: Option<Vec<Vec<String>>>,
        codeBlocks: Option<Vec<Vec<String>>>,
    ) -> Self {
        Self {
            content,
            line_count: lineCount,
            word_count: wordCount,
            character_count: characterCount,
            headers,
            links,
            code_blocks: codeBlocks,
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_line_count(&self) -> i64 {
        self.line_count.clone()
    }

    pub fn get_word_count(&self) -> i64 {
        self.word_count.clone()
    }

    pub fn get_character_count(&self) -> i64 {
        self.character_count.clone()
    }

    pub fn get_headers(&self) -> Option<Vec<String>> {
        self.headers.clone()
    }

    pub fn get_links(&self) -> Option<Vec<Vec<String>>> {
        self.links.clone()
    }

    pub fn get_code_blocks(&self) -> Option<Vec<Vec<String>>> {
        self.code_blocks.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PptxExtractionResult")]
#[serde(default, rename_all = "camelCase")]
pub struct PptxExtractionResult {
    /// Extracted text content from all slides
    #[php(prop, name = "content")]
    pub content: String,
    /// Presentation metadata
    pub metadata: PptxMetadata,
    /// Total number of slides
    #[php(prop, name = "slideCount")]
    pub slide_count: i64,
    /// Total number of embedded images
    #[php(prop, name = "imageCount")]
    pub image_count: i64,
    /// Total number of tables
    #[php(prop, name = "tableCount")]
    pub table_count: i64,
    /// Extracted images from the presentation
    pub images: Vec<ExtractedImage>,
    /// Slide structure with boundaries (when page tracking is enabled)
    pub page_structure: Option<PageStructure>,
    /// Per-slide content (when page tracking is enabled)
    pub page_contents: Option<Vec<PageContent>>,
    /// Structured document representation
    pub document: Option<DocumentStructure>,
    /// Hyperlinks discovered in slides as (url, optional_label) pairs.
    #[php(prop, name = "hyperlinks")]
    #[serde(skip)]
    pub hyperlinks: Vec<String>,
    /// Office metadata extracted from docProps/core.xml and docProps/app.xml.
    ///
    /// Contains keys like "title", "author", "created_by", "subject", "keywords",
    /// "modified_by", "created_at", "modified_at", etc.
    pub office_metadata: HashMap<String, String>,
    /// Slide comments as revisions.
    ///
    /// Each `<p:cm>` element in `ppt/comments/comment{N}.xml` becomes a
    /// `DocumentRevision { kind: Comment }` with author (resolved from
    /// `ppt/commentAuthors.xml`), ISO-8601 timestamp, and
    /// `RevisionAnchor::Slide { index }`. `None` when no comment XML parts exist.
    pub revisions: Option<Vec<DocumentRevision>>,
}

#[php_impl]
impl PptxExtractionResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: String, slideCount: i64, imageCount: i64, tableCount: i64, hyperlinks: Vec<String>) -> Self {
        Self {
            content,
            metadata: Default::default(),
            slide_count: slideCount,
            image_count: imageCount,
            table_count: tableCount,
            images: Default::default(),
            page_structure: Default::default(),
            page_contents: Default::default(),
            document: Default::default(),
            hyperlinks,
            office_metadata: Default::default(),
            revisions: Default::default(),
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_metadata(&self) -> PptxMetadata {
        self.metadata.clone()
    }

    pub fn get_slide_count(&self) -> i64 {
        self.slide_count.clone()
    }

    pub fn get_image_count(&self) -> i64 {
        self.image_count.clone()
    }

    pub fn get_table_count(&self) -> i64 {
        self.table_count.clone()
    }

    pub fn get_images(&self) -> Vec<ExtractedImage> {
        self.images.clone()
    }

    pub fn get_page_structure(&self) -> Option<PageStructure> {
        self.page_structure.clone().map(Into::into)
    }

    pub fn get_page_contents(&self) -> Option<Vec<PageContent>> {
        self.page_contents.clone()
    }

    pub fn get_document(&self) -> Option<DocumentStructure> {
        self.document.clone().map(Into::into)
    }

    pub fn get_hyperlinks(&self) -> Vec<String> {
        self.hyperlinks.clone()
    }

    pub fn get_office_metadata(&self) -> HashMap<String, String> {
        self.office_metadata.clone()
    }

    pub fn get_revisions(&self) -> Option<Vec<DocumentRevision>> {
        self.revisions.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\EmailExtractionResult")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct EmailExtractionResult {
    /// Email subject line
    #[php(prop, name = "subject")]
    pub subject: Option<String>,
    /// Sender email address
    #[php(prop, name = "fromEmail")]
    pub from_email: Option<String>,
    /// Primary recipient email addresses
    #[php(prop, name = "toEmails")]
    pub to_emails: Vec<String>,
    /// CC recipient email addresses
    #[php(prop, name = "ccEmails")]
    pub cc_emails: Vec<String>,
    /// BCC recipient email addresses
    #[php(prop, name = "bccEmails")]
    pub bcc_emails: Vec<String>,
    /// Email date/timestamp
    #[php(prop, name = "date")]
    pub date: Option<String>,
    /// Message-ID header value
    #[php(prop, name = "messageId")]
    pub message_id: Option<String>,
    /// Plain text version of the email body
    #[php(prop, name = "plainText")]
    pub plain_text: Option<String>,
    /// HTML version of the email body
    #[php(prop, name = "htmlContent")]
    pub html_content: Option<String>,
    /// Cleaned/processed text content. Aliased as `cleaned_text` for back-compat.
    #[php(prop, name = "content")]
    pub content: String,
    /// List of email attachments
    pub attachments: Vec<EmailAttachment>,
    /// Additional email headers and metadata
    pub metadata: HashMap<String, String>,
}

#[php_impl]
impl EmailExtractionResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        subject: Option<String>,
        fromEmail: Option<String>,
        toEmails: Vec<String>,
        ccEmails: Vec<String>,
        bccEmails: Vec<String>,
        date: Option<String>,
        messageId: Option<String>,
        plainText: Option<String>,
        htmlContent: Option<String>,
        content: String,
    ) -> Self {
        Self {
            subject,
            from_email: fromEmail,
            to_emails: toEmails,
            cc_emails: ccEmails,
            bcc_emails: bccEmails,
            date,
            message_id: messageId,
            plain_text: plainText,
            html_content: htmlContent,
            content,
            attachments: Default::default(),
            metadata: Default::default(),
        }
    }

    pub fn get_subject(&self) -> Option<String> {
        self.subject.clone()
    }

    pub fn get_from_email(&self) -> Option<String> {
        self.from_email.clone()
    }

    pub fn get_to_emails(&self) -> Vec<String> {
        self.to_emails.clone()
    }

    pub fn get_cc_emails(&self) -> Vec<String> {
        self.cc_emails.clone()
    }

    pub fn get_bcc_emails(&self) -> Vec<String> {
        self.bcc_emails.clone()
    }

    pub fn get_date(&self) -> Option<String> {
        self.date.clone()
    }

    pub fn get_message_id(&self) -> Option<String> {
        self.message_id.clone()
    }

    pub fn get_plain_text(&self) -> Option<String> {
        self.plain_text.clone()
    }

    pub fn get_html_content(&self) -> Option<String> {
        self.html_content.clone()
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_attachments(&self) -> Vec<EmailAttachment> {
        self.attachments.clone()
    }

    pub fn get_metadata(&self) -> HashMap<String, String> {
        self.metadata.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\EmailAttachment")]
#[serde(default, rename_all = "camelCase")]
pub struct EmailAttachment {
    /// Attachment name (from Content-Disposition header)
    #[php(prop, name = "name")]
    pub name: Option<String>,
    /// Filename of the attachment
    #[php(prop, name = "filename")]
    pub filename: Option<String>,
    /// MIME type of the attachment
    #[php(prop, name = "mimeType")]
    pub mime_type: Option<String>,
    /// Size in bytes
    #[php(prop, name = "size")]
    pub size: Option<i64>,
    /// Whether this attachment is an image
    #[php(prop, name = "isImage")]
    pub is_image: bool,
    /// Attachment data (if extracted).
    /// Uses `bytes::Bytes` for cheap cloning of large buffers.
    pub data: Option<Vec<u8>>,
}

#[php_impl]
impl EmailAttachment {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        name: Option<String>,
        filename: Option<String>,
        mimeType: Option<String>,
        size: Option<i64>,
        isImage: bool,
        data: Option<PhpBytes>,
    ) -> Self {
        Self {
            name,
            filename,
            mime_type: mimeType,
            size,
            is_image: isImage,
            data: data.map(|b| b.0),
        }
    }

    pub fn get_name(&self) -> Option<String> {
        self.name.clone()
    }

    pub fn get_filename(&self) -> Option<String> {
        self.filename.clone()
    }

    pub fn get_mime_type(&self) -> Option<String> {
        self.mime_type.clone()
    }

    pub fn get_size(&self) -> Option<i64> {
        self.size.clone()
    }

    pub fn get_is_image(&self) -> bool {
        self.is_image.clone()
    }

    pub fn get_data(&self) -> Option<Vec<u8>> {
        self.data.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OcrExtractionResult")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrExtractionResult {
    /// Recognized text content
    #[php(prop, name = "content")]
    pub content: String,
    /// Original MIME type of the processed image
    #[php(prop, name = "mimeType")]
    pub mime_type: String,
    /// OCR processing metadata (confidence scores, language, etc.)
    pub metadata: HashMap<String, serde_json::Value>,
    /// Tables detected and extracted via OCR
    pub tables: Vec<OcrTable>,
    /// Structured OCR elements with bounding boxes and confidence scores.
    /// Available when TSV output is requested or table detection is enabled.
    pub ocr_elements: Option<Vec<OcrElement>>,
    /// Structured document produced from hOCR parsing.
    /// Carries paragraph structure, bounding boxes, and confidence scores
    /// that the flattened `content` string discards.
    #[php(prop, name = "internalDocument")]
    #[serde(skip)]
    pub internal_document: Option<String>,
}

#[php_impl]
impl OcrExtractionResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: String, mimeType: String, internalDocument: Option<String>) -> Self {
        Self {
            content,
            mime_type: mimeType,
            metadata: Default::default(),
            tables: Default::default(),
            ocr_elements: Default::default(),
            internal_document: internalDocument,
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_mime_type(&self) -> String {
        self.mime_type.clone()
    }

    pub fn get_metadata(&self) -> Option<String> {
        serde_json::to_string(&self.metadata).ok()
    }

    pub fn get_tables(&self) -> Vec<OcrTable> {
        self.tables.clone()
    }

    pub fn get_ocr_elements(&self) -> Option<Vec<OcrElement>> {
        self.ocr_elements.clone()
    }

    pub fn get_internal_document(&self) -> Option<String> {
        self.internal_document.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OcrTable")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrTable {
    /// Table cells as a 2D vector (rows × columns)
    pub cells: Vec<Vec<String>>,
    /// Markdown representation of the table
    #[php(prop, name = "markdown")]
    pub markdown: String,
    /// Page number where the table was found (1-indexed)
    #[php(prop, name = "pageNumber")]
    pub page_number: u32,
    /// Bounding box of the table in pixel coordinates (from OCR word positions).
    pub bounding_box: Option<OcrTableBoundingBox>,
}

#[php_impl]
impl OcrTable {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(cells: Vec<Vec<String>>, markdown: String, pageNumber: u32) -> Self {
        Self {
            cells,
            markdown,
            page_number: pageNumber,
            bounding_box: Default::default(),
        }
    }

    pub fn get_cells(&self) -> Vec<Vec<String>> {
        self.cells.clone()
    }

    pub fn get_markdown(&self) -> String {
        self.markdown.clone()
    }

    pub fn get_page_number(&self) -> u32 {
        self.page_number.clone()
    }

    pub fn get_bounding_box(&self) -> Option<OcrTableBoundingBox> {
        self.bounding_box.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OcrTableBoundingBox")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrTableBoundingBox {
    /// Left x-coordinate (pixels)
    #[php(prop, name = "left")]
    pub left: u32,
    /// Top y-coordinate (pixels)
    #[php(prop, name = "top")]
    pub top: u32,
    /// Right x-coordinate (pixels)
    #[php(prop, name = "right")]
    pub right: u32,
    /// Bottom y-coordinate (pixels)
    #[php(prop, name = "bottom")]
    pub bottom: u32,
}

#[php_impl]
impl OcrTableBoundingBox {
    #[php(constructor)]
    pub fn new(left: u32, top: u32, right: u32, bottom: u32) -> Self {
        Self {
            left,
            top,
            right,
            bottom,
        }
    }

    pub fn get_left(&self) -> u32 {
        self.left.clone()
    }

    pub fn get_top(&self) -> u32 {
        self.top.clone()
    }

    pub fn get_right(&self) -> u32 {
        self.right.clone()
    }

    pub fn get_bottom(&self) -> u32 {
        self.bottom.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ImagePreprocessingConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct ImagePreprocessingConfig {
    /// Target DPI for the image (300 is standard, 600 for small text).
    #[php(prop, name = "targetDpi")]
    pub target_dpi: i32,
    /// Auto-detect and correct image rotation.
    #[php(prop, name = "autoRotate")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub auto_rotate: bool,
    /// Correct skew (tilted images).
    #[php(prop, name = "deskew")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub deskew: bool,
    /// Remove noise from the image.
    #[php(prop, name = "denoise")]
    pub denoise: bool,
    /// Enhance contrast for better text visibility.
    #[php(prop, name = "contrastEnhance")]
    pub contrast_enhance: bool,
    /// Binarization method: "otsu", "sauvola", "adaptive".
    #[php(prop, name = "binarizationMethod")]
    pub binarization_method: String,
    /// Invert colors (white text on black → black on white).
    #[php(prop, name = "invertColors")]
    pub invert_colors: bool,
}

impl Default for ImagePreprocessingConfig {
    fn default() -> Self {
        <kreuzberg::ImagePreprocessingConfig as Default>::default().into()
    }
}

#[php_impl]
impl ImagePreprocessingConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        targetDpi: i32,
        autoRotate: bool,
        deskew: bool,
        denoise: bool,
        contrastEnhance: bool,
        binarizationMethod: String,
        invertColors: bool,
    ) -> Self {
        Self {
            target_dpi: targetDpi,
            auto_rotate: autoRotate,
            deskew,
            denoise,
            contrast_enhance: contrastEnhance,
            binarization_method: binarizationMethod,
            invert_colors: invertColors,
        }
    }

    pub fn get_target_dpi(&self) -> i32 {
        self.target_dpi.clone()
    }

    pub fn get_auto_rotate(&self) -> bool {
        self.auto_rotate.clone()
    }

    pub fn get_deskew(&self) -> bool {
        self.deskew.clone()
    }

    pub fn get_denoise(&self) -> bool {
        self.denoise.clone()
    }

    pub fn get_contrast_enhance(&self) -> bool {
        self.contrast_enhance.clone()
    }

    pub fn get_binarization_method(&self) -> String {
        self.binarization_method.clone()
    }

    pub fn get_invert_colors(&self) -> bool {
        self.invert_colors.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> ImagePreprocessingConfig {
        kreuzberg::ImagePreprocessingConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TesseractConfig")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct TesseractConfig {
    /// Language code (e.g., "eng", "deu", "fra")
    #[php(prop, name = "language")]
    pub language: String,
    /// Page Segmentation Mode (0-13).
    ///
    /// Common values:
    /// - 3: Fully automatic page segmentation (native default)
    /// - 6: Assume a single uniform block of text (WASM default — avoids layout-analysis hang)
    /// - 11: Sparse text with no particular order
    #[php(prop, name = "psm")]
    pub psm: i32,
    /// Output format ("text" or "markdown")
    #[php(prop, name = "outputFormat")]
    pub output_format: String,
    /// OCR Engine Mode (0-3).
    ///
    /// - 0: Legacy engine only
    /// - 1: Neural nets (LSTM) only (usually best)
    /// - 2: Legacy + LSTM
    /// - 3: Default (based on what's available)
    #[php(prop, name = "oem")]
    pub oem: i32,
    /// Minimum confidence threshold (0.0-100.0).
    ///
    /// Words with confidence below this threshold may be rejected or flagged.
    #[php(prop, name = "minConfidence")]
    pub min_confidence: f64,
    /// Image preprocessing configuration.
    ///
    /// Controls how images are preprocessed before OCR. Can significantly
    /// improve quality for scanned documents or low-quality images.
    pub preprocessing: Option<ImagePreprocessingConfig>,
    /// Enable automatic table detection and reconstruction
    #[php(prop, name = "enableTableDetection")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub enable_table_detection: bool,
    /// Minimum confidence threshold for table detection (0.0-1.0)
    #[php(prop, name = "tableMinConfidence")]
    pub table_min_confidence: f64,
    /// Column threshold for table detection (pixels)
    #[php(prop, name = "tableColumnThreshold")]
    pub table_column_threshold: i32,
    /// Row threshold ratio for table detection (0.0-1.0)
    #[php(prop, name = "tableRowThresholdRatio")]
    pub table_row_threshold_ratio: f64,
    /// Enable OCR result caching
    #[php(prop, name = "useCache")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub use_cache: bool,
    /// Use pre-adapted templates for character classification
    #[php(prop, name = "classifyUsePreAdaptedTemplates")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub classify_use_pre_adapted_templates: bool,
    /// Enable N-gram language model
    #[php(prop, name = "languageModelNgramOn")]
    pub language_model_ngram_on: bool,
    /// Don't reject good words during block-level processing
    #[php(prop, name = "tesseditDontBlkrejGoodWds")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub tessedit_dont_blkrej_good_wds: bool,
    /// Don't reject good words during row-level processing
    #[php(prop, name = "tesseditDontRowrejGoodWds")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub tessedit_dont_rowrej_good_wds: bool,
    /// Enable dictionary correction
    #[php(prop, name = "tesseditEnableDictCorrection")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub tessedit_enable_dict_correction: bool,
    /// Whitelist of allowed characters (empty = all allowed)
    #[php(prop, name = "tesseditCharWhitelist")]
    pub tessedit_char_whitelist: String,
    /// Blacklist of forbidden characters (empty = none forbidden)
    #[php(prop, name = "tesseditCharBlacklist")]
    pub tessedit_char_blacklist: String,
    /// Use primary language params model
    #[php(prop, name = "tesseditUsePrimaryParamsModel")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub tessedit_use_primary_params_model: bool,
    /// Variable-width space detection
    #[php(prop, name = "textordSpaceSizeIsVariable")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub textord_space_size_is_variable: bool,
    /// Use adaptive thresholding method
    #[php(prop, name = "thresholdingMethod")]
    pub thresholding_method: bool,
}

impl Default for TesseractConfig {
    fn default() -> Self {
        <kreuzberg::TesseractConfig as Default>::default().into()
    }
}

#[php_impl]
impl TesseractConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        language: String,
        psm: i32,
        outputFormat: String,
        oem: i32,
        minConfidence: f64,
        enableTableDetection: bool,
        tableMinConfidence: f64,
        tableColumnThreshold: i32,
        tableRowThresholdRatio: f64,
        useCache: bool,
        classifyUsePreAdaptedTemplates: bool,
        languageModelNgramOn: bool,
        tesseditDontBlkrejGoodWds: bool,
        tesseditDontRowrejGoodWds: bool,
        tesseditEnableDictCorrection: bool,
        tesseditCharWhitelist: String,
        tesseditCharBlacklist: String,
        tesseditUsePrimaryParamsModel: bool,
        textordSpaceSizeIsVariable: bool,
        thresholdingMethod: bool,
    ) -> Self {
        Self {
            language,
            psm,
            output_format: outputFormat,
            oem,
            min_confidence: minConfidence,
            preprocessing: Default::default(),
            enable_table_detection: enableTableDetection,
            table_min_confidence: tableMinConfidence,
            table_column_threshold: tableColumnThreshold,
            table_row_threshold_ratio: tableRowThresholdRatio,
            use_cache: useCache,
            classify_use_pre_adapted_templates: classifyUsePreAdaptedTemplates,
            language_model_ngram_on: languageModelNgramOn,
            tessedit_dont_blkrej_good_wds: tesseditDontBlkrejGoodWds,
            tessedit_dont_rowrej_good_wds: tesseditDontRowrejGoodWds,
            tessedit_enable_dict_correction: tesseditEnableDictCorrection,
            tessedit_char_whitelist: tesseditCharWhitelist,
            tessedit_char_blacklist: tesseditCharBlacklist,
            tessedit_use_primary_params_model: tesseditUsePrimaryParamsModel,
            textord_space_size_is_variable: textordSpaceSizeIsVariable,
            thresholding_method: thresholdingMethod,
        }
    }

    pub fn get_language(&self) -> String {
        self.language.clone()
    }

    pub fn get_psm(&self) -> i32 {
        self.psm.clone()
    }

    pub fn get_output_format(&self) -> String {
        self.output_format.clone()
    }

    pub fn get_oem(&self) -> i32 {
        self.oem.clone()
    }

    pub fn get_min_confidence(&self) -> f64 {
        self.min_confidence.clone()
    }

    pub fn get_preprocessing(&self) -> Option<ImagePreprocessingConfig> {
        self.preprocessing.clone().map(Into::into)
    }

    pub fn get_enable_table_detection(&self) -> bool {
        self.enable_table_detection.clone()
    }

    pub fn get_table_min_confidence(&self) -> f64 {
        self.table_min_confidence.clone()
    }

    pub fn get_table_column_threshold(&self) -> i32 {
        self.table_column_threshold.clone()
    }

    pub fn get_table_row_threshold_ratio(&self) -> f64 {
        self.table_row_threshold_ratio.clone()
    }

    pub fn get_use_cache(&self) -> bool {
        self.use_cache.clone()
    }

    pub fn get_classify_use_pre_adapted_templates(&self) -> bool {
        self.classify_use_pre_adapted_templates.clone()
    }

    pub fn get_language_model_ngram_on(&self) -> bool {
        self.language_model_ngram_on.clone()
    }

    pub fn get_tessedit_dont_blkrej_good_wds(&self) -> bool {
        self.tessedit_dont_blkrej_good_wds.clone()
    }

    pub fn get_tessedit_dont_rowrej_good_wds(&self) -> bool {
        self.tessedit_dont_rowrej_good_wds.clone()
    }

    pub fn get_tessedit_enable_dict_correction(&self) -> bool {
        self.tessedit_enable_dict_correction.clone()
    }

    pub fn get_tessedit_char_whitelist(&self) -> String {
        self.tessedit_char_whitelist.clone()
    }

    pub fn get_tessedit_char_blacklist(&self) -> String {
        self.tessedit_char_blacklist.clone()
    }

    pub fn get_tessedit_use_primary_params_model(&self) -> bool {
        self.tessedit_use_primary_params_model.clone()
    }

    pub fn get_textord_space_size_is_variable(&self) -> bool {
        self.textord_space_size_is_variable.clone()
    }

    pub fn get_thresholding_method(&self) -> bool {
        self.thresholding_method.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> TesseractConfig {
        kreuzberg::TesseractConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ImagePreprocessingMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ImagePreprocessingMetadata {
    /// Original image dimensions (width, height) in pixels
    #[php(prop, name = "originalDimensions")]
    #[serde(skip)]
    pub original_dimensions: Vec<i64>,
    /// Original image DPI (horizontal, vertical)
    #[php(prop, name = "originalDpi")]
    #[serde(skip)]
    pub original_dpi: Vec<f64>,
    /// Target DPI from configuration
    #[php(prop, name = "targetDpi")]
    pub target_dpi: i32,
    /// Scaling factor applied to the image
    #[php(prop, name = "scaleFactor")]
    pub scale_factor: f64,
    /// Whether DPI was auto-adjusted based on content
    #[php(prop, name = "autoAdjusted")]
    pub auto_adjusted: bool,
    /// Final DPI after processing
    #[php(prop, name = "finalDpi")]
    pub final_dpi: i32,
    /// New dimensions after resizing (if resized)
    #[php(prop, name = "newDimensions")]
    #[serde(skip)]
    pub new_dimensions: Option<Vec<i64>>,
    /// Resampling algorithm used ("LANCZOS3", "CATMULLROM", etc.)
    #[php(prop, name = "resampleMethod")]
    pub resample_method: String,
    /// Whether dimensions were clamped to max_image_dimension
    #[php(prop, name = "dimensionClamped")]
    pub dimension_clamped: bool,
    /// Calculated optimal DPI (if auto_adjust_dpi enabled)
    #[php(prop, name = "calculatedDpi")]
    pub calculated_dpi: Option<i32>,
    /// Whether resize was skipped (dimensions already optimal)
    #[php(prop, name = "skippedResize")]
    pub skipped_resize: bool,
    /// Error message if resize failed
    #[php(prop, name = "resizeError")]
    pub resize_error: Option<String>,
}

#[php_impl]
impl ImagePreprocessingMetadata {
    #[php(constructor)]
    pub fn new(
        originalDimensions: Vec<i64>,
        originalDpi: Vec<f64>,
        targetDpi: i32,
        scaleFactor: f64,
        autoAdjusted: bool,
        finalDpi: i32,
        newDimensions: Option<Vec<i64>>,
        resampleMethod: String,
        dimensionClamped: bool,
        calculatedDpi: Option<i32>,
        skippedResize: bool,
        resizeError: Option<String>,
    ) -> Self {
        Self {
            original_dimensions: originalDimensions,
            original_dpi: originalDpi,
            target_dpi: targetDpi,
            scale_factor: scaleFactor,
            auto_adjusted: autoAdjusted,
            final_dpi: finalDpi,
            new_dimensions: newDimensions,
            resample_method: resampleMethod,
            dimension_clamped: dimensionClamped,
            calculated_dpi: calculatedDpi,
            skipped_resize: skippedResize,
            resize_error: resizeError,
        }
    }

    pub fn get_original_dimensions(&self) -> Vec<i64> {
        self.original_dimensions.clone()
    }

    pub fn get_original_dpi(&self) -> Vec<f64> {
        self.original_dpi.clone()
    }

    pub fn get_target_dpi(&self) -> i32 {
        self.target_dpi.clone()
    }

    pub fn get_scale_factor(&self) -> f64 {
        self.scale_factor.clone()
    }

    pub fn get_auto_adjusted(&self) -> bool {
        self.auto_adjusted.clone()
    }

    pub fn get_final_dpi(&self) -> i32 {
        self.final_dpi.clone()
    }

    pub fn get_new_dimensions(&self) -> Option<Vec<i64>> {
        self.new_dimensions.clone()
    }

    pub fn get_resample_method(&self) -> String {
        self.resample_method.clone()
    }

    pub fn get_dimension_clamped(&self) -> bool {
        self.dimension_clamped.clone()
    }

    pub fn get_calculated_dpi(&self) -> Option<i32> {
        self.calculated_dpi.clone()
    }

    pub fn get_skipped_resize(&self) -> bool {
        self.skipped_resize.clone()
    }

    pub fn get_resize_error(&self) -> Option<String> {
        self.resize_error.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\Metadata")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct Metadata {
    /// Document title
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Document subject or description
    #[php(prop, name = "subject")]
    pub subject: Option<String>,
    /// Primary author(s) - always Vec for consistency
    #[php(prop, name = "authors")]
    pub authors: Option<Vec<String>>,
    /// Keywords/tags - always Vec for consistency
    #[php(prop, name = "keywords")]
    pub keywords: Option<Vec<String>>,
    /// Primary language (ISO 639 code)
    #[php(prop, name = "language")]
    pub language: Option<String>,
    /// Creation timestamp (ISO 8601 format)
    #[php(prop, name = "createdAt")]
    pub created_at: Option<String>,
    /// Last modification timestamp (ISO 8601 format)
    #[php(prop, name = "modifiedAt")]
    pub modified_at: Option<String>,
    /// User who created the document
    #[php(prop, name = "createdBy")]
    pub created_by: Option<String>,
    /// User who last modified the document
    #[php(prop, name = "modifiedBy")]
    pub modified_by: Option<String>,
    /// Page/slide/sheet structure with boundaries
    pub pages: Option<PageStructure>,
    /// Format-specific metadata (discriminated union)
    ///
    /// Contains detailed metadata specific to the document format.
    /// Serialized as a nested `"format"` object with a `format_type` discriminator field.
    pub format: Option<FormatMetadata>,
    /// Image preprocessing metadata (when OCR preprocessing was applied)
    pub image_preprocessing: Option<ImagePreprocessingMetadata>,
    /// JSON schema (for structured data extraction)
    pub json_schema: Option<serde_json::Value>,
    /// Error metadata (for batch operations)
    pub error: Option<ErrorMetadata>,
    /// Extraction duration in milliseconds (for benchmarking).
    ///
    /// This field is populated by batch extraction to provide per-file timing
    /// information. It's `None` for single-file extraction (which uses external timing).
    #[php(prop, name = "extractionDurationMs")]
    pub extraction_duration_ms: Option<i64>,
    /// Document category (from frontmatter or classification).
    #[php(prop, name = "category")]
    pub category: Option<String>,
    /// Document tags (from frontmatter).
    #[php(prop, name = "tags")]
    pub tags: Option<Vec<String>>,
    /// Document version string (from frontmatter).
    #[php(prop, name = "documentVersion")]
    pub document_version: Option<String>,
    /// Abstract or summary text (from frontmatter).
    #[php(prop, name = "abstractText")]
    pub abstract_text: Option<String>,
    /// Output format identifier (e.g., "markdown", "html", "text").
    ///
    /// Set by the output format pipeline stage when format conversion is applied.
    /// Previously stored in `metadata.additional["output_format"]`.
    #[php(prop, name = "outputFormat")]
    pub output_format: Option<String>,
    /// Whether OCR was used during extraction.
    ///
    /// Set to `true` whenever the extraction pipeline ran an OCR backend
    /// (Tesseract, PaddleOCR, VLM, etc.) and used that output as the primary
    /// or fallback text. `false` means native text extraction was used exclusively.
    #[php(prop, name = "ocrUsed")]
    pub ocr_used: bool,
    /// Additional custom fields from postprocessors.
    ///
    /// Serialized as a nested `"additional"` object (not flattened at root level).
    /// Uses `Cow<'static, str>` keys so static string keys avoid allocation.
    pub additional: HashMap<String, serde_json::Value>,
}

impl Default for Metadata {
    fn default() -> Self {
        <kreuzberg::Metadata as Default>::default().into()
    }
}

#[php_impl]
impl Metadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        title: Option<String>,
        subject: Option<String>,
        authors: Option<Vec<String>>,
        keywords: Option<Vec<String>>,
        language: Option<String>,
        createdAt: Option<String>,
        modifiedAt: Option<String>,
        createdBy: Option<String>,
        modifiedBy: Option<String>,
        extractionDurationMs: Option<i64>,
        category: Option<String>,
        tags: Option<Vec<String>>,
        documentVersion: Option<String>,
        abstractText: Option<String>,
        outputFormat: Option<String>,
        ocrUsed: bool,
    ) -> Self {
        Self {
            title,
            subject,
            authors,
            keywords,
            language,
            created_at: createdAt,
            modified_at: modifiedAt,
            created_by: createdBy,
            modified_by: modifiedBy,
            pages: Default::default(),
            format: Default::default(),
            image_preprocessing: Default::default(),
            json_schema: Default::default(),
            error: Default::default(),
            extraction_duration_ms: extractionDurationMs,
            category,
            tags,
            document_version: documentVersion,
            abstract_text: abstractText,
            output_format: outputFormat,
            ocr_used: ocrUsed,
            additional: Default::default(),
        }
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_subject(&self) -> Option<String> {
        self.subject.clone()
    }

    pub fn get_authors(&self) -> Option<Vec<String>> {
        self.authors.clone()
    }

    pub fn get_keywords(&self) -> Option<Vec<String>> {
        self.keywords.clone()
    }

    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    pub fn get_created_at(&self) -> Option<String> {
        self.created_at.clone()
    }

    pub fn get_modified_at(&self) -> Option<String> {
        self.modified_at.clone()
    }

    pub fn get_created_by(&self) -> Option<String> {
        self.created_by.clone()
    }

    pub fn get_modified_by(&self) -> Option<String> {
        self.modified_by.clone()
    }

    pub fn get_pages(&self) -> Option<PageStructure> {
        self.pages.clone().map(Into::into)
    }

    pub fn get_format(&self) -> Option<FormatMetadata> {
        self.format.clone().map(Into::into)
    }

    pub fn get_image_preprocessing(&self) -> Option<ImagePreprocessingMetadata> {
        self.image_preprocessing.clone().map(Into::into)
    }

    pub fn get_json_schema(&self) -> Option<String> {
        self.json_schema.as_ref().and_then(|v| serde_json::to_string(v).ok())
    }

    pub fn get_error(&self) -> Option<ErrorMetadata> {
        self.error.clone().map(Into::into)
    }

    pub fn get_extraction_duration_ms(&self) -> Option<i64> {
        self.extraction_duration_ms.clone()
    }

    pub fn get_category(&self) -> Option<String> {
        self.category.clone()
    }

    pub fn get_tags(&self) -> Option<Vec<String>> {
        self.tags.clone()
    }

    pub fn get_document_version(&self) -> Option<String> {
        self.document_version.clone()
    }

    pub fn get_abstract_text(&self) -> Option<String> {
        self.abstract_text.clone()
    }

    pub fn get_output_format(&self) -> Option<String> {
        self.output_format.clone()
    }

    pub fn get_ocr_used(&self) -> bool {
        self.ocr_used.clone()
    }

    pub fn get_additional(&self) -> Option<String> {
        serde_json::to_string(&self.additional).ok()
    }

    pub fn is_empty(&self) -> bool {
        let core_self = kreuzberg::Metadata {
            title: self.title.clone(),
            subject: self.subject.clone(),
            authors: self.authors.clone(),
            keywords: self.keywords.clone(),
            language: self.language.clone(),
            created_at: self.created_at.clone(),
            modified_at: self.modified_at.clone(),
            created_by: self.created_by.clone(),
            modified_by: self.modified_by.clone(),
            pages: self.pages.clone().map(Into::into),
            format: self.format.clone().map(Into::into),
            image_preprocessing: self.image_preprocessing.clone().map(Into::into),
            json_schema: Default::default(),
            error: self.error.clone().map(Into::into),
            extraction_duration_ms: self.extraction_duration_ms.map(|v| v as u64),
            category: self.category.clone(),
            tags: self.tags.clone(),
            document_version: self.document_version.clone(),
            abstract_text: self.abstract_text.clone(),
            output_format: self.output_format.clone(),
            ocr_used: self.ocr_used,
            additional: Default::default(),
        };
        core_self.is_empty()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ExcelMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ExcelMetadata {
    /// Number of sheets in the workbook.
    #[php(prop, name = "sheetCount")]
    pub sheet_count: Option<u32>,
    /// Names of all sheets in the workbook.
    #[php(prop, name = "sheetNames")]
    pub sheet_names: Option<Vec<String>>,
}

impl Default for ExcelMetadata {
    fn default() -> Self {
        <kreuzberg::ExcelMetadata as Default>::default().into()
    }
}

#[php_impl]
impl ExcelMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_sheet_count(&self) -> Option<u32> {
        self.sheet_count.clone()
    }

    pub fn get_sheet_names(&self) -> Option<Vec<String>> {
        self.sheet_names.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\EmailMetadata")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct EmailMetadata {
    /// Sender's email address
    #[php(prop, name = "fromEmail")]
    pub from_email: Option<String>,
    /// Sender's display name
    #[php(prop, name = "fromName")]
    pub from_name: Option<String>,
    /// Primary recipients
    #[php(prop, name = "toEmails")]
    pub to_emails: Vec<String>,
    /// CC recipients
    #[php(prop, name = "ccEmails")]
    pub cc_emails: Vec<String>,
    /// BCC recipients
    #[php(prop, name = "bccEmails")]
    pub bcc_emails: Vec<String>,
    /// Message-ID header value
    #[php(prop, name = "messageId")]
    pub message_id: Option<String>,
    /// List of attachment filenames
    #[php(prop, name = "attachments")]
    pub attachments: Vec<String>,
}

impl Default for EmailMetadata {
    fn default() -> Self {
        <kreuzberg::EmailMetadata as Default>::default().into()
    }
}

#[php_impl]
impl EmailMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        fromEmail: Option<String>,
        fromName: Option<String>,
        toEmails: Vec<String>,
        ccEmails: Vec<String>,
        bccEmails: Vec<String>,
        messageId: Option<String>,
        attachments: Vec<String>,
    ) -> Self {
        Self {
            from_email: fromEmail,
            from_name: fromName,
            to_emails: toEmails,
            cc_emails: ccEmails,
            bcc_emails: bccEmails,
            message_id: messageId,
            attachments,
        }
    }

    pub fn get_from_email(&self) -> Option<String> {
        self.from_email.clone()
    }

    pub fn get_from_name(&self) -> Option<String> {
        self.from_name.clone()
    }

    pub fn get_to_emails(&self) -> Vec<String> {
        self.to_emails.clone()
    }

    pub fn get_cc_emails(&self) -> Vec<String> {
        self.cc_emails.clone()
    }

    pub fn get_bcc_emails(&self) -> Vec<String> {
        self.bcc_emails.clone()
    }

    pub fn get_message_id(&self) -> Option<String> {
        self.message_id.clone()
    }

    pub fn get_attachments(&self) -> Vec<String> {
        self.attachments.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ArchiveMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ArchiveMetadata {
    /// Archive format ("ZIP", "TAR", "7Z", etc.)
    #[php(prop, name = "format")]
    pub format: String,
    /// Total number of files in the archive
    #[php(prop, name = "fileCount")]
    pub file_count: u32,
    /// List of file paths within the archive
    #[php(prop, name = "fileList")]
    pub file_list: Vec<String>,
    /// Total uncompressed size in bytes
    #[php(prop, name = "totalSize")]
    pub total_size: i64,
    /// Compressed size in bytes (if available)
    #[php(prop, name = "compressedSize")]
    pub compressed_size: Option<i64>,
}

impl Default for ArchiveMetadata {
    fn default() -> Self {
        <kreuzberg::ArchiveMetadata as Default>::default().into()
    }
}

#[php_impl]
impl ArchiveMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        format: String,
        fileCount: u32,
        fileList: Vec<String>,
        totalSize: i64,
        compressedSize: Option<i64>,
    ) -> Self {
        Self {
            format,
            file_count: fileCount,
            file_list: fileList,
            total_size: totalSize,
            compressed_size: compressedSize,
        }
    }

    pub fn get_format(&self) -> String {
        self.format.clone()
    }

    pub fn get_file_count(&self) -> u32 {
        self.file_count.clone()
    }

    pub fn get_file_list(&self) -> Vec<String> {
        self.file_list.clone()
    }

    pub fn get_total_size(&self) -> i64 {
        self.total_size.clone()
    }

    pub fn get_compressed_size(&self) -> Option<i64> {
        self.compressed_size.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\ImageMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ImageMetadata {
    /// Image width in pixels
    #[php(prop, name = "width")]
    pub width: u32,
    /// Image height in pixels
    #[php(prop, name = "height")]
    pub height: u32,
    /// Image format (e.g., "PNG", "JPEG", "TIFF")
    #[php(prop, name = "format")]
    pub format: String,
    /// EXIF metadata tags
    pub exif: HashMap<String, String>,
}

impl Default for ImageMetadata {
    fn default() -> Self {
        <kreuzberg::ImageMetadata as Default>::default().into()
    }
}

#[php_impl]
impl ImageMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(width: u32, height: u32, format: String) -> Self {
        Self {
            width,
            height,
            format,
            exif: Default::default(),
        }
    }

    pub fn get_width(&self) -> u32 {
        self.width.clone()
    }

    pub fn get_height(&self) -> u32 {
        self.height.clone()
    }

    pub fn get_format(&self) -> String {
        self.format.clone()
    }

    pub fn get_exif(&self) -> HashMap<String, String> {
        self.exif.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\XmlMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct XmlMetadata {
    /// Total number of XML elements processed
    #[php(prop, name = "elementCount")]
    pub element_count: u32,
    /// List of unique element tag names (sorted)
    #[php(prop, name = "uniqueElements")]
    pub unique_elements: Vec<String>,
}

impl Default for XmlMetadata {
    fn default() -> Self {
        <kreuzberg::XmlMetadata as Default>::default().into()
    }
}

#[php_impl]
impl XmlMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(elementCount: u32, uniqueElements: Vec<String>) -> Self {
        Self {
            element_count: elementCount,
            unique_elements: uniqueElements,
        }
    }

    pub fn get_element_count(&self) -> u32 {
        self.element_count.clone()
    }

    pub fn get_unique_elements(&self) -> Vec<String> {
        self.unique_elements.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TextMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct TextMetadata {
    /// Number of lines in the document
    #[php(prop, name = "lineCount")]
    pub line_count: u32,
    /// Number of words
    #[php(prop, name = "wordCount")]
    pub word_count: u32,
    /// Number of characters
    #[php(prop, name = "characterCount")]
    pub character_count: u32,
    /// Markdown headers (headings text only, for Markdown files)
    #[php(prop, name = "headers")]
    pub headers: Option<Vec<String>>,
    /// Markdown links as (text, url) tuples (for Markdown files)
    #[serde(skip)]
    pub links: Option<Vec<Vec<String>>>,
    /// Code blocks as (language, code) tuples (for Markdown files)
    #[serde(skip)]
    pub code_blocks: Option<Vec<Vec<String>>>,
}

impl Default for TextMetadata {
    fn default() -> Self {
        <kreuzberg::TextMetadata as Default>::default().into()
    }
}

#[php_impl]
impl TextMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        lineCount: u32,
        wordCount: u32,
        characterCount: u32,
        headers: Option<Vec<String>>,
        links: Option<Vec<Vec<String>>>,
        codeBlocks: Option<Vec<Vec<String>>>,
    ) -> Self {
        Self {
            line_count: lineCount,
            word_count: wordCount,
            character_count: characterCount,
            headers,
            links,
            code_blocks: codeBlocks,
        }
    }

    pub fn get_line_count(&self) -> u32 {
        self.line_count.clone()
    }

    pub fn get_word_count(&self) -> u32 {
        self.word_count.clone()
    }

    pub fn get_character_count(&self) -> u32 {
        self.character_count.clone()
    }

    pub fn get_headers(&self) -> Option<Vec<String>> {
        self.headers.clone()
    }

    pub fn get_links(&self) -> Option<Vec<Vec<String>>> {
        self.links.clone()
    }

    pub fn get_code_blocks(&self) -> Option<Vec<Vec<String>>> {
        self.code_blocks.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\HeaderMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct HeaderMetadata {
    /// Header level: 1 (h1) through 6 (h6)
    #[php(prop, name = "level")]
    pub level: u8,
    /// Normalized text content of the header
    #[php(prop, name = "text")]
    pub text: String,
    /// HTML id attribute if present
    #[php(prop, name = "id")]
    pub id: Option<String>,
    /// Document tree depth at the header element
    #[php(prop, name = "depth")]
    pub depth: u32,
    /// Byte offset in original HTML document
    #[php(prop, name = "htmlOffset")]
    pub html_offset: u32,
}

#[php_impl]
impl HeaderMetadata {
    #[php(constructor)]
    pub fn new(level: u8, text: String, id: Option<String>, depth: u32, htmlOffset: u32) -> Self {
        Self {
            level,
            text,
            id,
            depth,
            html_offset: htmlOffset,
        }
    }

    pub fn get_level(&self) -> u8 {
        self.level.clone()
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_id(&self) -> Option<String> {
        self.id.clone()
    }

    pub fn get_depth(&self) -> u32 {
        self.depth.clone()
    }

    pub fn get_html_offset(&self) -> u32 {
        self.html_offset.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\LinkMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct LinkMetadata {
    /// The href URL value
    #[php(prop, name = "href")]
    pub href: String,
    /// Link text content (normalized)
    #[php(prop, name = "text")]
    pub text: String,
    /// Optional title attribute
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Link type classification
    #[php(prop, name = "linkType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub link_type: String,
    /// Rel attribute values
    #[php(prop, name = "rel")]
    pub rel: Vec<String>,
    /// Additional attributes as key-value pairs
    #[serde(skip)]
    pub attributes: Vec<Vec<String>>,
}

#[php_impl]
impl LinkMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        href: String,
        text: String,
        title: Option<String>,
        linkType: String,
        rel: Vec<String>,
        attributes: Vec<Vec<String>>,
    ) -> Self {
        Self {
            href,
            text,
            title,
            link_type: linkType,
            rel,
            attributes,
        }
    }

    pub fn get_href(&self) -> String {
        self.href.clone()
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_link_type(&self) -> String {
        self.link_type.clone()
    }

    pub fn get_rel(&self) -> Vec<String> {
        self.rel.clone()
    }

    pub fn get_attributes(&self) -> Vec<Vec<String>> {
        self.attributes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ImageMetadataType")]
#[serde(default, rename_all = "camelCase")]
pub struct ImageMetadataType {
    /// Image source (URL, data URI, or SVG content)
    #[php(prop, name = "src")]
    pub src: String,
    /// Alternative text from alt attribute
    #[php(prop, name = "alt")]
    pub alt: Option<String>,
    /// Title attribute
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Image dimensions as (width, height) if available
    #[php(prop, name = "dimensions")]
    #[serde(skip)]
    pub dimensions: Option<Vec<u32>>,
    /// Image type classification
    #[php(prop, name = "imageType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub image_type: String,
    /// Additional attributes as key-value pairs
    #[serde(skip)]
    pub attributes: Vec<Vec<String>>,
}

#[php_impl]
impl ImageMetadataType {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        src: String,
        alt: Option<String>,
        title: Option<String>,
        dimensions: Option<Vec<u32>>,
        imageType: String,
        attributes: Vec<Vec<String>>,
    ) -> Self {
        Self {
            src,
            alt,
            title,
            dimensions,
            image_type: imageType,
            attributes,
        }
    }

    pub fn get_src(&self) -> String {
        self.src.clone()
    }

    pub fn get_alt(&self) -> Option<String> {
        self.alt.clone()
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_dimensions(&self) -> Option<Vec<u32>> {
        self.dimensions.clone()
    }

    pub fn get_image_type(&self) -> String {
        self.image_type.clone()
    }

    pub fn get_attributes(&self) -> Vec<Vec<String>> {
        self.attributes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\StructuredData")]
#[serde(default, rename_all = "camelCase")]
pub struct StructuredData {
    /// Type of structured data
    #[php(prop, name = "dataType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub data_type: String,
    /// Raw JSON string representation
    #[php(prop, name = "rawJson")]
    pub raw_json: String,
    /// Schema type if detectable (e.g., "Article", "Event", "Product")
    #[php(prop, name = "schemaType")]
    pub schema_type: Option<String>,
}

#[php_impl]
impl StructuredData {
    #[php(constructor)]
    pub fn new(dataType: String, rawJson: String, schemaType: Option<String>) -> Self {
        Self {
            data_type: dataType,
            raw_json: rawJson,
            schema_type: schemaType,
        }
    }

    pub fn get_data_type(&self) -> String {
        self.data_type.clone()
    }

    pub fn get_raw_json(&self) -> String {
        self.raw_json.clone()
    }

    pub fn get_schema_type(&self) -> Option<String> {
        self.schema_type.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\HtmlMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct HtmlMetadata {
    /// Document title from `<title>` tag
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Document description from `<meta name="description">` tag
    #[php(prop, name = "description")]
    pub description: Option<String>,
    /// Document keywords from `<meta name="keywords">` tag, split on commas
    #[php(prop, name = "keywords")]
    pub keywords: Vec<String>,
    /// Document author from `<meta name="author">` tag
    #[php(prop, name = "author")]
    pub author: Option<String>,
    /// Canonical URL from `<link rel="canonical">` tag
    #[php(prop, name = "canonicalUrl")]
    pub canonical_url: Option<String>,
    /// Base URL from `<base href="">` tag for resolving relative URLs
    #[php(prop, name = "baseHref")]
    pub base_href: Option<String>,
    /// Document language from `lang` attribute
    #[php(prop, name = "language")]
    pub language: Option<String>,
    /// Document text direction from `dir` attribute
    #[php(prop, name = "textDirection")]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text_direction: Option<String>,
    /// Open Graph metadata (og:* properties) for social media
    /// Keys like "title", "description", "image", "url", etc.
    pub open_graph: HashMap<String, String>,
    /// Twitter Card metadata (twitter:* properties)
    /// Keys like "card", "site", "creator", "title", "description", "image", etc.
    pub twitter_card: HashMap<String, String>,
    /// Additional meta tags not covered by specific fields
    /// Keys are meta name/property attributes, values are content
    pub meta_tags: HashMap<String, String>,
    /// Extracted header elements with hierarchy
    pub headers: Vec<HeaderMetadata>,
    /// Extracted hyperlinks with type classification
    pub links: Vec<LinkMetadata>,
    /// Extracted images with source and dimensions
    pub images: Vec<ImageMetadataType>,
    /// Extracted structured data blocks
    pub structured_data: Vec<StructuredData>,
}

impl Default for HtmlMetadata {
    fn default() -> Self {
        <kreuzberg::HtmlMetadata as Default>::default().into()
    }
}

#[php_impl]
impl HtmlMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        title: Option<String>,
        description: Option<String>,
        keywords: Vec<String>,
        author: Option<String>,
        canonicalUrl: Option<String>,
        baseHref: Option<String>,
        language: Option<String>,
        textDirection: Option<String>,
    ) -> Self {
        Self {
            title,
            description,
            keywords,
            author,
            canonical_url: canonicalUrl,
            base_href: baseHref,
            language,
            text_direction: textDirection,
            open_graph: Default::default(),
            twitter_card: Default::default(),
            meta_tags: Default::default(),
            headers: Default::default(),
            links: Default::default(),
            images: Default::default(),
            structured_data: Default::default(),
        }
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_description(&self) -> Option<String> {
        self.description.clone()
    }

    pub fn get_keywords(&self) -> Vec<String> {
        self.keywords.clone()
    }

    pub fn get_author(&self) -> Option<String> {
        self.author.clone()
    }

    pub fn get_canonical_url(&self) -> Option<String> {
        self.canonical_url.clone()
    }

    pub fn get_base_href(&self) -> Option<String> {
        self.base_href.clone()
    }

    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    pub fn get_text_direction(&self) -> Option<String> {
        self.text_direction.clone().map(Into::into)
    }

    pub fn get_open_graph(&self) -> HashMap<String, String> {
        self.open_graph.clone()
    }

    pub fn get_twitter_card(&self) -> HashMap<String, String> {
        self.twitter_card.clone()
    }

    pub fn get_meta_tags(&self) -> HashMap<String, String> {
        self.meta_tags.clone()
    }

    pub fn get_headers(&self) -> Vec<HeaderMetadata> {
        self.headers.clone()
    }

    pub fn get_links(&self) -> Vec<LinkMetadata> {
        self.links.clone()
    }

    pub fn get_images(&self) -> Vec<ImageMetadataType> {
        self.images.clone()
    }

    pub fn get_structured_data(&self) -> Vec<StructuredData> {
        self.structured_data.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\OcrMetadata")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct OcrMetadata {
    /// OCR language code(s) used
    #[php(prop, name = "language")]
    pub language: String,
    /// Tesseract Page Segmentation Mode (PSM)
    #[php(prop, name = "psm")]
    pub psm: i32,
    /// Output format (e.g., "text", "hocr")
    #[php(prop, name = "outputFormat")]
    pub output_format: String,
    /// Number of tables detected
    #[php(prop, name = "tableCount")]
    pub table_count: u32,
    #[php(prop, name = "tableRows")]
    pub table_rows: Option<u32>,
    #[php(prop, name = "tableCols")]
    pub table_cols: Option<u32>,
}

impl Default for OcrMetadata {
    fn default() -> Self {
        <kreuzberg::OcrMetadata as Default>::default().into()
    }
}

#[php_impl]
impl OcrMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        language: String,
        psm: i32,
        outputFormat: String,
        tableCount: u32,
        tableRows: Option<u32>,
        tableCols: Option<u32>,
    ) -> Self {
        Self {
            language,
            psm,
            output_format: outputFormat,
            table_count: tableCount,
            table_rows: tableRows,
            table_cols: tableCols,
        }
    }

    pub fn get_language(&self) -> String {
        self.language.clone()
    }

    pub fn get_psm(&self) -> i32 {
        self.psm.clone()
    }

    pub fn get_output_format(&self) -> String {
        self.output_format.clone()
    }

    pub fn get_table_count(&self) -> u32 {
        self.table_count.clone()
    }

    pub fn get_table_rows(&self) -> Option<u32> {
        self.table_rows.clone()
    }

    pub fn get_table_cols(&self) -> Option<u32> {
        self.table_cols.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ErrorMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct ErrorMetadata {
    #[php(prop, name = "errorType")]
    pub error_type: String,
    #[php(prop, name = "message")]
    pub message: String,
}

#[php_impl]
impl ErrorMetadata {
    #[php(constructor)]
    pub fn new(errorType: String, message: String) -> Self {
        Self {
            error_type: errorType,
            message,
        }
    }

    pub fn get_error_type(&self) -> String {
        self.error_type.clone()
    }

    pub fn get_message(&self) -> String {
        self.message.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PptxMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct PptxMetadata {
    /// Total number of slides in the presentation
    #[php(prop, name = "slideCount")]
    pub slide_count: u32,
    /// Names of slides (if available)
    #[php(prop, name = "slideNames")]
    pub slide_names: Vec<String>,
    /// Number of embedded images
    #[php(prop, name = "imageCount")]
    pub image_count: Option<u32>,
    /// Number of tables
    #[php(prop, name = "tableCount")]
    pub table_count: Option<u32>,
}

impl Default for PptxMetadata {
    fn default() -> Self {
        <kreuzberg::PptxMetadata as Default>::default().into()
    }
}

#[php_impl]
impl PptxMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(slideCount: u32, slideNames: Vec<String>, imageCount: Option<u32>, tableCount: Option<u32>) -> Self {
        Self {
            slide_count: slideCount,
            slide_names: slideNames,
            image_count: imageCount,
            table_count: tableCount,
        }
    }

    pub fn get_slide_count(&self) -> u32 {
        self.slide_count.clone()
    }

    pub fn get_slide_names(&self) -> Vec<String> {
        self.slide_names.clone()
    }

    pub fn get_image_count(&self) -> Option<u32> {
        self.image_count.clone()
    }

    pub fn get_table_count(&self) -> Option<u32> {
        self.table_count.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\DocxMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct DocxMetadata {
    /// Core properties from docProps/core.xml (Dublin Core metadata)
    ///
    /// Contains title, creator, subject, keywords, dates, etc.
    /// Shared format across DOCX/PPTX/XLSX documents.
    pub core_properties: Option<CoreProperties>,
    /// Application properties from docProps/app.xml (Word-specific statistics)
    ///
    /// Contains word count, page count, paragraph count, editing time, etc.
    /// DOCX-specific variant of Office application properties.
    pub app_properties: Option<DocxAppProperties>,
    /// Custom properties from docProps/custom.xml (user-defined properties)
    ///
    /// Contains key-value pairs defined by users or applications.
    /// Values can be strings, numbers, booleans, or dates.
    pub custom_properties: Option<HashMap<String, serde_json::Value>>,
}

impl Default for DocxMetadata {
    fn default() -> Self {
        <kreuzberg::DocxMetadata as Default>::default().into()
    }
}

#[php_impl]
impl DocxMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_core_properties(&self) -> Option<CoreProperties> {
        self.core_properties.clone().map(Into::into)
    }

    pub fn get_app_properties(&self) -> Option<DocxAppProperties> {
        self.app_properties.clone().map(Into::into)
    }

    pub fn get_custom_properties(&self) -> Option<String> {
        self.custom_properties
            .as_ref()
            .and_then(|v| serde_json::to_string(v).ok())
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\CsvMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct CsvMetadata {
    #[php(prop, name = "rowCount")]
    pub row_count: u32,
    #[php(prop, name = "columnCount")]
    pub column_count: u32,
    #[php(prop, name = "delimiter")]
    pub delimiter: Option<String>,
    #[php(prop, name = "hasHeader")]
    pub has_header: bool,
    #[php(prop, name = "columnTypes")]
    pub column_types: Option<Vec<String>>,
}

impl Default for CsvMetadata {
    fn default() -> Self {
        <kreuzberg::CsvMetadata as Default>::default().into()
    }
}

#[php_impl]
impl CsvMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        rowCount: u32,
        columnCount: u32,
        delimiter: Option<String>,
        hasHeader: bool,
        columnTypes: Option<Vec<String>>,
    ) -> Self {
        Self {
            row_count: rowCount,
            column_count: columnCount,
            delimiter,
            has_header: hasHeader,
            column_types: columnTypes,
        }
    }

    pub fn get_row_count(&self) -> u32 {
        self.row_count.clone()
    }

    pub fn get_column_count(&self) -> u32 {
        self.column_count.clone()
    }

    pub fn get_delimiter(&self) -> Option<String> {
        self.delimiter.clone()
    }

    pub fn get_has_header(&self) -> bool {
        self.has_header.clone()
    }

    pub fn get_column_types(&self) -> Option<Vec<String>> {
        self.column_types.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\BibtexMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct BibtexMetadata {
    /// Number of entries in the bibliography.
    #[php(prop, name = "entryCount")]
    pub entry_count: i64,
    #[php(prop, name = "citationKeys")]
    pub citation_keys: Vec<String>,
    #[php(prop, name = "authors")]
    pub authors: Vec<String>,
    pub year_range: Option<YearRange>,
    pub entry_types: Option<HashMap<String, i64>>,
}

impl Default for BibtexMetadata {
    fn default() -> Self {
        <kreuzberg::BibtexMetadata as Default>::default().into()
    }
}

#[php_impl]
impl BibtexMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(entryCount: i64, citationKeys: Vec<String>, authors: Vec<String>) -> Self {
        Self {
            entry_count: entryCount,
            citation_keys: citationKeys,
            authors,
            year_range: Default::default(),
            entry_types: Default::default(),
        }
    }

    pub fn get_entry_count(&self) -> i64 {
        self.entry_count.clone()
    }

    pub fn get_citation_keys(&self) -> Vec<String> {
        self.citation_keys.clone()
    }

    pub fn get_authors(&self) -> Vec<String> {
        self.authors.clone()
    }

    pub fn get_year_range(&self) -> Option<YearRange> {
        self.year_range.clone().map(Into::into)
    }

    pub fn get_entry_types(&self) -> Option<HashMap<String, i64>> {
        self.entry_types.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\CitationMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct CitationMetadata {
    #[php(prop, name = "citationCount")]
    pub citation_count: i64,
    #[php(prop, name = "format")]
    pub format: Option<String>,
    #[php(prop, name = "authors")]
    pub authors: Vec<String>,
    pub year_range: Option<YearRange>,
    #[php(prop, name = "dois")]
    pub dois: Vec<String>,
    #[php(prop, name = "keywords")]
    pub keywords: Vec<String>,
}

impl Default for CitationMetadata {
    fn default() -> Self {
        <kreuzberg::CitationMetadata as Default>::default().into()
    }
}

#[php_impl]
impl CitationMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        citationCount: i64,
        format: Option<String>,
        authors: Vec<String>,
        dois: Vec<String>,
        keywords: Vec<String>,
    ) -> Self {
        Self {
            citation_count: citationCount,
            format,
            authors,
            year_range: Default::default(),
            dois,
            keywords,
        }
    }

    pub fn get_citation_count(&self) -> i64 {
        self.citation_count.clone()
    }

    pub fn get_format(&self) -> Option<String> {
        self.format.clone()
    }

    pub fn get_authors(&self) -> Vec<String> {
        self.authors.clone()
    }

    pub fn get_year_range(&self) -> Option<YearRange> {
        self.year_range.clone().map(Into::into)
    }

    pub fn get_dois(&self) -> Vec<String> {
        self.dois.clone()
    }

    pub fn get_keywords(&self) -> Vec<String> {
        self.keywords.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\YearRange")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct YearRange {
    #[php(prop, name = "min")]
    pub min: Option<u32>,
    #[php(prop, name = "max")]
    pub max: Option<u32>,
    #[php(prop, name = "years")]
    pub years: Vec<u32>,
}

#[php_impl]
impl YearRange {
    #[php(constructor)]
    pub fn new(min: Option<u32>, max: Option<u32>, years: Vec<u32>) -> Self {
        Self { min, max, years }
    }

    pub fn get_min(&self) -> Option<u32> {
        self.min.clone()
    }

    pub fn get_max(&self) -> Option<u32> {
        self.max.clone()
    }

    pub fn get_years(&self) -> Vec<u32> {
        self.years.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\FictionBookMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct FictionBookMetadata {
    #[php(prop, name = "genres")]
    pub genres: Vec<String>,
    #[php(prop, name = "sequences")]
    pub sequences: Vec<String>,
    #[php(prop, name = "annotation")]
    pub annotation: Option<String>,
}

impl Default for FictionBookMetadata {
    fn default() -> Self {
        <kreuzberg::FictionBookMetadata as Default>::default().into()
    }
}

#[php_impl]
impl FictionBookMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(genres: Vec<String>, sequences: Vec<String>, annotation: Option<String>) -> Self {
        Self {
            genres,
            sequences,
            annotation,
        }
    }

    pub fn get_genres(&self) -> Vec<String> {
        self.genres.clone()
    }

    pub fn get_sequences(&self) -> Vec<String> {
        self.sequences.clone()
    }

    pub fn get_annotation(&self) -> Option<String> {
        self.annotation.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\DbfMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct DbfMetadata {
    #[php(prop, name = "recordCount")]
    pub record_count: i64,
    #[php(prop, name = "fieldCount")]
    pub field_count: i64,
    pub fields: Vec<DbfFieldInfo>,
}

impl Default for DbfMetadata {
    fn default() -> Self {
        <kreuzberg::DbfMetadata as Default>::default().into()
    }
}

#[php_impl]
impl DbfMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(recordCount: i64, fieldCount: i64) -> Self {
        Self {
            record_count: recordCount,
            field_count: fieldCount,
            fields: Default::default(),
        }
    }

    pub fn get_record_count(&self) -> i64 {
        self.record_count.clone()
    }

    pub fn get_field_count(&self) -> i64 {
        self.field_count.clone()
    }

    pub fn get_fields(&self) -> Vec<DbfFieldInfo> {
        self.fields.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DbfFieldInfo")]
#[serde(default, rename_all = "camelCase")]
pub struct DbfFieldInfo {
    #[php(prop, name = "name")]
    pub name: String,
    #[php(prop, name = "fieldType")]
    pub field_type: String,
}

#[php_impl]
impl DbfFieldInfo {
    #[php(constructor)]
    pub fn new(name: String, fieldType: String) -> Self {
        Self {
            name,
            field_type: fieldType,
        }
    }

    pub fn get_name(&self) -> String {
        self.name.clone()
    }

    pub fn get_field_type(&self) -> String {
        self.field_type.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\JatsMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct JatsMetadata {
    #[php(prop, name = "copyright")]
    pub copyright: Option<String>,
    #[php(prop, name = "license")]
    pub license: Option<String>,
    pub history_dates: HashMap<String, String>,
    pub contributor_roles: Vec<ContributorRole>,
}

impl Default for JatsMetadata {
    fn default() -> Self {
        <kreuzberg::JatsMetadata as Default>::default().into()
    }
}

#[php_impl]
impl JatsMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_copyright(&self) -> Option<String> {
        self.copyright.clone()
    }

    pub fn get_license(&self) -> Option<String> {
        self.license.clone()
    }

    pub fn get_history_dates(&self) -> HashMap<String, String> {
        self.history_dates.clone()
    }

    pub fn get_contributor_roles(&self) -> Vec<ContributorRole> {
        self.contributor_roles.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ContributorRole")]
#[serde(default, rename_all = "camelCase")]
pub struct ContributorRole {
    #[php(prop, name = "name")]
    pub name: String,
    #[php(prop, name = "role")]
    pub role: Option<String>,
}

#[php_impl]
impl ContributorRole {
    #[php(constructor)]
    pub fn new(name: String, role: Option<String>) -> Self {
        Self { name, role }
    }

    pub fn get_name(&self) -> String {
        self.name.clone()
    }

    pub fn get_role(&self) -> Option<String> {
        self.role.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\EpubMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct EpubMetadata {
    #[php(prop, name = "coverage")]
    pub coverage: Option<String>,
    #[php(prop, name = "dcFormat")]
    pub dc_format: Option<String>,
    #[php(prop, name = "relation")]
    pub relation: Option<String>,
    #[php(prop, name = "source")]
    pub source: Option<String>,
    #[php(prop, name = "dcType")]
    pub dc_type: Option<String>,
    #[php(prop, name = "coverImage")]
    pub cover_image: Option<String>,
}

impl Default for EpubMetadata {
    fn default() -> Self {
        <kreuzberg::EpubMetadata as Default>::default().into()
    }
}

#[php_impl]
impl EpubMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_coverage(&self) -> Option<String> {
        self.coverage.clone()
    }

    pub fn get_dc_format(&self) -> Option<String> {
        self.dc_format.clone()
    }

    pub fn get_relation(&self) -> Option<String> {
        self.relation.clone()
    }

    pub fn get_source(&self) -> Option<String> {
        self.source.clone()
    }

    pub fn get_dc_type(&self) -> Option<String> {
        self.dc_type.clone()
    }

    pub fn get_cover_image(&self) -> Option<String> {
        self.cover_image.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PstMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct PstMetadata {
    #[php(prop, name = "messageCount")]
    pub message_count: i64,
}

impl Default for PstMetadata {
    fn default() -> Self {
        <kreuzberg::PstMetadata as Default>::default().into()
    }
}

#[php_impl]
impl PstMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(messageCount: i64) -> Self {
        Self {
            message_count: messageCount,
        }
    }

    pub fn get_message_count(&self) -> i64 {
        self.message_count.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\OcrConfidence")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrConfidence {
    /// Detection confidence: how confident the OCR engine is that text exists here.
    ///
    /// PaddleOCR provides this as `box_score`, Tesseract doesn't have a direct equivalent.
    /// Range: 0.0 to 1.0 (or None if not available).
    #[php(prop, name = "detection")]
    pub detection: Option<f64>,
    /// Recognition confidence: how confident about the text content.
    ///
    /// Range: 0.0 to 1.0.
    #[php(prop, name = "recognition")]
    pub recognition: f64,
}

impl Default for OcrConfidence {
    fn default() -> Self {
        <kreuzberg::OcrConfidence as Default>::default().into()
    }
}

#[php_impl]
impl OcrConfidence {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(detection: Option<f64>, recognition: f64) -> Self {
        Self { detection, recognition }
    }

    pub fn get_detection(&self) -> Option<f64> {
        self.detection.clone()
    }

    pub fn get_recognition(&self) -> f64 {
        self.recognition.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OcrRotation")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrRotation {
    /// Rotation angle in degrees (0, 90, 180, 270 for PaddleOCR).
    #[php(prop, name = "angleDegrees")]
    pub angle_degrees: f64,
    /// Confidence score for the rotation detection.
    #[php(prop, name = "confidence")]
    pub confidence: Option<f64>,
}

#[php_impl]
impl OcrRotation {
    #[php(constructor)]
    pub fn new(angleDegrees: f64, confidence: Option<f64>) -> Self {
        Self {
            angle_degrees: angleDegrees,
            confidence,
        }
    }

    pub fn get_angle_degrees(&self) -> f64 {
        self.angle_degrees.clone()
    }

    pub fn get_confidence(&self) -> Option<f64> {
        self.confidence.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\OcrElement")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrElement {
    /// The recognized text content.
    #[php(prop, name = "text")]
    pub text: String,
    /// Bounding geometry (rectangle or quadrilateral).
    pub geometry: OcrBoundingGeometry,
    /// Confidence scores for detection and recognition.
    pub confidence: OcrConfidence,
    /// Hierarchical level (word, line, block, page).
    #[php(prop, name = "level")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub level: String,
    /// Rotation information (if detected).
    pub rotation: Option<OcrRotation>,
    /// Page number (1-indexed).
    #[php(prop, name = "pageNumber")]
    pub page_number: u32,
    /// Parent element ID for hierarchical relationships.
    ///
    /// Only used for Tesseract output which has word -> line -> block hierarchy.
    #[php(prop, name = "parentId")]
    pub parent_id: Option<String>,
    /// Backend-specific metadata that doesn't fit the unified schema.
    pub backend_metadata: HashMap<String, serde_json::Value>,
}

impl Default for OcrElement {
    fn default() -> Self {
        <kreuzberg::OcrElement as Default>::default().into()
    }
}

#[php_impl]
impl OcrElement {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(text: String, level: String, pageNumber: u32, parentId: Option<String>) -> Self {
        Self {
            text,
            geometry: Default::default(),
            confidence: Default::default(),
            level,
            rotation: Default::default(),
            page_number: pageNumber,
            parent_id: parentId,
            backend_metadata: Default::default(),
        }
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_geometry(&self) -> OcrBoundingGeometry {
        self.geometry.clone()
    }

    pub fn get_confidence(&self) -> OcrConfidence {
        self.confidence.clone()
    }

    pub fn get_level(&self) -> String {
        self.level.clone()
    }

    pub fn get_rotation(&self) -> Option<OcrRotation> {
        self.rotation.clone().map(Into::into)
    }

    pub fn get_page_number(&self) -> u32 {
        self.page_number.clone()
    }

    pub fn get_parent_id(&self) -> Option<String> {
        self.parent_id.clone()
    }

    pub fn get_backend_metadata(&self) -> Option<String> {
        serde_json::to_string(&self.backend_metadata).ok()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\OcrElementConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct OcrElementConfig {
    /// Whether to include OCR elements in the extraction result.
    ///
    /// When true, the `ocr_elements` field in `ExtractionResult` will be populated.
    #[php(prop, name = "includeElements")]
    pub include_elements: bool,
    /// Minimum hierarchical level to include.
    ///
    /// Elements below this level (e.g., words when min_level is Line) will be excluded.
    #[php(prop, name = "minLevel")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub min_level: String,
    /// Minimum recognition confidence threshold (0.0-1.0).
    ///
    /// Elements with confidence below this threshold will be filtered out.
    #[php(prop, name = "minConfidence")]
    pub min_confidence: f64,
    /// Whether to build hierarchical relationships between elements.
    ///
    /// When true, `parent_id` fields will be populated based on spatial containment.
    /// Only meaningful for Tesseract output.
    #[php(prop, name = "buildHierarchy")]
    pub build_hierarchy: bool,
}

impl Default for OcrElementConfig {
    fn default() -> Self {
        <kreuzberg::OcrElementConfig as Default>::default().into()
    }
}

#[php_impl]
impl OcrElementConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(includeElements: bool, minLevel: String, minConfidence: f64, buildHierarchy: bool) -> Self {
        Self {
            include_elements: includeElements,
            min_level: minLevel,
            min_confidence: minConfidence,
            build_hierarchy: buildHierarchy,
        }
    }

    pub fn get_include_elements(&self) -> bool {
        self.include_elements.clone()
    }

    pub fn get_min_level(&self) -> String {
        self.min_level.clone()
    }

    pub fn get_min_confidence(&self) -> f64 {
        self.min_confidence.clone()
    }

    pub fn get_build_hierarchy(&self) -> bool {
        self.build_hierarchy.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PageStructure")]
#[serde(default, rename_all = "camelCase")]
pub struct PageStructure {
    /// Total number of pages/slides/sheets
    #[php(prop, name = "totalCount")]
    pub total_count: u32,
    /// Type of paginated unit
    #[php(prop, name = "unitType")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub unit_type: String,
    /// Character offset boundaries for each page
    ///
    /// Maps character ranges in the extracted content to page numbers.
    /// Used for chunk page range calculation.
    pub boundaries: Option<Vec<PageBoundary>>,
    /// Detailed per-page metadata (optional, only when needed)
    pub pages: Option<Vec<PageInfo>>,
}

#[php_impl]
impl PageStructure {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(totalCount: u32, unitType: String) -> Self {
        Self {
            total_count: totalCount,
            unit_type: unitType,
            boundaries: Default::default(),
            pages: Default::default(),
        }
    }

    pub fn get_total_count(&self) -> u32 {
        self.total_count.clone()
    }

    pub fn get_unit_type(&self) -> String {
        self.unit_type.clone()
    }

    pub fn get_boundaries(&self) -> Option<Vec<PageBoundary>> {
        self.boundaries.clone()
    }

    pub fn get_pages(&self) -> Option<Vec<PageInfo>> {
        self.pages.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PageBoundary")]
#[serde(default, rename_all = "camelCase")]
pub struct PageBoundary {
    /// Byte offset where this page starts in the content string (UTF-8 valid boundary, inclusive)
    #[php(prop, name = "byteStart")]
    pub byte_start: i64,
    /// Byte offset where this page ends in the content string (UTF-8 valid boundary, exclusive)
    #[php(prop, name = "byteEnd")]
    pub byte_end: i64,
    /// Page number (1-indexed)
    #[php(prop, name = "pageNumber")]
    pub page_number: u32,
}

#[php_impl]
impl PageBoundary {
    #[php(constructor)]
    pub fn new(byteStart: i64, byteEnd: i64, pageNumber: u32) -> Self {
        Self {
            byte_start: byteStart,
            byte_end: byteEnd,
            page_number: pageNumber,
        }
    }

    pub fn get_byte_start(&self) -> i64 {
        self.byte_start.clone()
    }

    pub fn get_byte_end(&self) -> i64 {
        self.byte_end.clone()
    }

    pub fn get_page_number(&self) -> u32 {
        self.page_number.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PageInfo")]
#[serde(default, rename_all = "camelCase")]
pub struct PageInfo {
    /// Page number (1-indexed)
    #[php(prop, name = "number")]
    pub number: u32,
    /// Page title (usually for presentations)
    #[php(prop, name = "title")]
    pub title: Option<String>,
    /// Dimensions in points (PDF) or pixels (images): (width, height)
    #[php(prop, name = "dimensions")]
    #[serde(skip)]
    pub dimensions: Option<Vec<f64>>,
    /// Number of images on this page
    #[php(prop, name = "imageCount")]
    pub image_count: Option<u32>,
    /// Number of tables on this page
    #[php(prop, name = "tableCount")]
    pub table_count: Option<u32>,
    /// Whether this page is hidden (e.g., in presentations)
    #[php(prop, name = "hidden")]
    pub hidden: Option<bool>,
    /// Whether this page is blank (no meaningful text, no images, no tables)
    ///
    /// A page is considered blank if it has fewer than 3 non-whitespace characters
    /// and contains no tables or images. This is useful for filtering out empty pages
    /// in scanned documents or PDFs with blank separator pages.
    #[php(prop, name = "isBlank")]
    pub is_blank: Option<bool>,
    /// Whether this page contains non-trivial vector graphics (paths, shapes, curves)
    ///
    /// Indicates the presence of vector-drawn content such as charts, diagrams,
    /// or geometric shapes (e.g., from Adobe InDesign, LaTeX TikZ). These are
    /// invisible to `ExtractionResult.images` since they are not embedded as raster
    /// XObjects. Set to `true` when path count exceeds a heuristic threshold,
    /// signaling that downstream consumers may want to rasterize the page to
    /// capture this content.
    ///
    /// Only populated for PDFs; `None` for other document types.
    #[php(prop, name = "hasVectorGraphics")]
    pub has_vector_graphics: bool,
}

#[php_impl]
impl PageInfo {
    #[php(constructor)]
    pub fn new(
        number: u32,
        title: Option<String>,
        dimensions: Option<Vec<f64>>,
        imageCount: Option<u32>,
        tableCount: Option<u32>,
        hidden: Option<bool>,
        isBlank: Option<bool>,
        hasVectorGraphics: bool,
    ) -> Self {
        Self {
            number,
            title,
            dimensions,
            image_count: imageCount,
            table_count: tableCount,
            hidden,
            is_blank: isBlank,
            has_vector_graphics: hasVectorGraphics,
        }
    }

    pub fn get_number(&self) -> u32 {
        self.number.clone()
    }

    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    pub fn get_dimensions(&self) -> Option<Vec<f64>> {
        self.dimensions.clone()
    }

    pub fn get_image_count(&self) -> Option<u32> {
        self.image_count.clone()
    }

    pub fn get_table_count(&self) -> Option<u32> {
        self.table_count.clone()
    }

    pub fn get_hidden(&self) -> Option<bool> {
        self.hidden.clone()
    }

    pub fn get_is_blank(&self) -> Option<bool> {
        self.is_blank.clone()
    }

    pub fn get_has_vector_graphics(&self) -> bool {
        self.has_vector_graphics.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PageContent")]
#[serde(default, rename_all = "camelCase")]
pub struct PageContent {
    /// Page number (1-indexed)
    #[php(prop, name = "pageNumber")]
    pub page_number: u32,
    /// Text content for this page
    #[php(prop, name = "content")]
    pub content: String,
    /// Tables found on this page (uses Arc for memory efficiency)
    ///
    /// Serializes as Vec<Table> for JSON compatibility while maintaining
    /// Arc semantics in-memory for zero-copy sharing.
    pub tables: Vec<Table>,
    /// Indices into `ExtractionResult.images` for images found on this page.
    ///
    /// Each value is a zero-based index into the top-level `images` collection.
    /// Only populated when `extract_images = true` in the extraction config.
    #[php(prop, name = "imageIndices")]
    pub image_indices: Vec<u32>,
    /// Hierarchy information for the page (when hierarchy extraction is enabled)
    ///
    /// Contains text hierarchy levels (H1-H6) extracted from the page content.
    pub hierarchy: Option<PageHierarchy>,
    /// Whether this page is blank (no meaningful text content)
    ///
    /// Determined during extraction based on text content analysis.
    /// A page is blank if it has fewer than 3 non-whitespace characters
    /// and contains no tables or images.
    #[php(prop, name = "isBlank")]
    pub is_blank: Option<bool>,
    /// Layout detection regions for this page (when layout detection is enabled).
    ///
    /// Contains detected layout regions with class, confidence, bounding box,
    /// and area fraction. Only populated when layout detection is configured.
    pub layout_regions: Option<Vec<LayoutRegion>>,
    /// Speaker notes for this slide (PPTX only).
    ///
    /// Contains the text from the slide's notes pane (`ppt/notesSlides/notesSlide{N}.xml`).
    /// Only populated when the source is a PPTX file and notes are present.
    #[php(prop, name = "speakerNotes")]
    pub speaker_notes: Option<String>,
    /// Section name this slide belongs to (PPTX only).
    ///
    /// PowerPoint sections group slides into logical chapters (`<p:sectionLst>` in
    /// `ppt/presentation.xml`). Only populated when the source is a PPTX file and
    /// the slide belongs to a named section.
    #[php(prop, name = "sectionName")]
    pub section_name: Option<String>,
    /// Sheet name for this page (XLSX/ODS only).
    ///
    /// Each spreadsheet sheet maps to one `PageContent` entry. This field carries the
    /// sheet's display name as it appears in the workbook. `None` for all non-spreadsheet
    /// formats and for sheets with an empty name.
    #[php(prop, name = "sheetName")]
    pub sheet_name: Option<String>,
}

#[php_impl]
impl PageContent {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        pageNumber: u32,
        content: String,
        imageIndices: Vec<u32>,
        isBlank: Option<bool>,
        speakerNotes: Option<String>,
        sectionName: Option<String>,
        sheetName: Option<String>,
    ) -> Self {
        Self {
            page_number: pageNumber,
            content,
            tables: Default::default(),
            image_indices: imageIndices,
            hierarchy: Default::default(),
            is_blank: isBlank,
            layout_regions: Default::default(),
            speaker_notes: speakerNotes,
            section_name: sectionName,
            sheet_name: sheetName,
        }
    }

    pub fn get_page_number(&self) -> u32 {
        self.page_number.clone()
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_tables(&self) -> Vec<Table> {
        self.tables.clone()
    }

    pub fn get_image_indices(&self) -> Vec<u32> {
        self.image_indices.clone()
    }

    pub fn get_hierarchy(&self) -> Option<PageHierarchy> {
        self.hierarchy.clone().map(Into::into)
    }

    pub fn get_is_blank(&self) -> Option<bool> {
        self.is_blank.clone()
    }

    pub fn get_layout_regions(&self) -> Option<Vec<LayoutRegion>> {
        self.layout_regions.clone()
    }

    pub fn get_speaker_notes(&self) -> Option<String> {
        self.speaker_notes.clone()
    }

    pub fn get_section_name(&self) -> Option<String> {
        self.section_name.clone()
    }

    pub fn get_sheet_name(&self) -> Option<String> {
        self.sheet_name.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\LayoutRegion")]
#[serde(default, rename_all = "camelCase")]
pub struct LayoutRegion {
    /// Layout class name (e.g. "picture", "table", "text", "section_header").
    #[php(prop, name = "className")]
    pub class_name: String,
    /// Confidence score from the layout detection model (0.0 to 1.0).
    #[php(prop, name = "confidence")]
    pub confidence: f64,
    /// Bounding box in document coordinate space.
    pub bounding_box: BoundingBox,
    /// Fraction of the page area covered by this region (0.0 to 1.0).
    #[php(prop, name = "areaFraction")]
    pub area_fraction: f64,
}

impl Default for LayoutRegion {
    fn default() -> Self {
        <kreuzberg::LayoutRegion as Default>::default().into()
    }
}

#[php_impl]
impl LayoutRegion {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(className: String, confidence: f64, areaFraction: f64) -> Self {
        Self {
            class_name: className,
            confidence,
            bounding_box: Default::default(),
            area_fraction: areaFraction,
        }
    }

    pub fn get_class_name(&self) -> String {
        self.class_name.clone()
    }

    pub fn get_confidence(&self) -> f64 {
        self.confidence.clone()
    }

    pub fn get_bounding_box(&self) -> BoundingBox {
        self.bounding_box.clone()
    }

    pub fn get_area_fraction(&self) -> f64 {
        self.area_fraction.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\PageHierarchy")]
#[serde(default, rename_all = "camelCase")]
pub struct PageHierarchy {
    /// Number of hierarchy blocks on this page
    #[php(prop, name = "blockCount")]
    pub block_count: u32,
    /// Hierarchical blocks with heading levels
    pub blocks: Vec<HierarchicalBlock>,
}

#[php_impl]
impl PageHierarchy {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(blockCount: u32) -> Self {
        Self {
            block_count: blockCount,
            blocks: Default::default(),
        }
    }

    pub fn get_block_count(&self) -> u32 {
        self.block_count.clone()
    }

    pub fn get_blocks(&self) -> Vec<HierarchicalBlock> {
        self.blocks.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\HierarchicalBlock")]
#[serde(default, rename_all = "camelCase")]
pub struct HierarchicalBlock {
    /// The text content of this block
    #[php(prop, name = "text")]
    pub text: String,
    /// The font size of the text in this block
    #[php(prop, name = "fontSize")]
    pub font_size: f32,
    /// The hierarchy level of this block (H1-H6 or Body)
    ///
    /// Levels correspond to HTML heading tags:
    /// - "h1": Top-level heading
    /// - "h2": Secondary heading
    /// - "h3": Tertiary heading
    /// - "h4": Quaternary heading
    /// - "h5": Quinary heading
    /// - "h6": Senary heading
    /// - "body": Body text (no heading level)
    #[php(prop, name = "level")]
    pub level: String,
    /// Bounding box information for the block
    ///
    /// Contains coordinates as (left, top, right, bottom) in PDF units.
    #[php(prop, name = "bbox")]
    #[serde(skip)]
    pub bbox: Option<Vec<f32>>,
}

#[php_impl]
impl HierarchicalBlock {
    #[php(constructor)]
    pub fn new(text: String, fontSize: f32, level: String, bbox: Option<Vec<f32>>) -> Self {
        Self {
            text,
            font_size: fontSize,
            level,
            bbox,
        }
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_font_size(&self) -> f32 {
        self.font_size.clone()
    }

    pub fn get_level(&self) -> String {
        self.level.clone()
    }

    pub fn get_bbox(&self) -> Option<Vec<f32>> {
        self.bbox.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\CellChange")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct CellChange {
    /// Zero-based row index.
    #[php(prop, name = "row")]
    pub row: i64,
    /// Zero-based column index.
    #[php(prop, name = "col")]
    pub col: i64,
    /// Value before the change.
    #[php(prop, name = "from")]
    pub from: String,
    /// Value after the change.
    #[php(prop, name = "to")]
    pub to: String,
}

#[php_impl]
impl CellChange {
    #[php(constructor)]
    pub fn new(row: i64, col: i64, from: String, to: String) -> Self {
        Self { row, col, from, to }
    }

    pub fn get_row(&self) -> i64 {
        self.row.clone()
    }

    pub fn get_col(&self) -> i64 {
        self.col.clone()
    }

    pub fn get_from(&self) -> String {
        self.from.clone()
    }

    pub fn get_to(&self) -> String {
        self.to.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DocumentRevision")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct DocumentRevision {
    /// Format-specific revision identifier.
    ///
    /// For DOCX this is the `w:id` attribute value on the change element
    /// (e.g. `"42"`). When the attribute is absent a synthetic fallback is
    /// generated (`"docx-ins-0"`, `"docx-del-3"`, …).
    #[php(prop, name = "revisionId")]
    pub revision_id: String,
    /// Display name of the author who made this change, when available.
    #[php(prop, name = "author")]
    pub author: Option<String>,
    /// ISO-8601 timestamp of the change, when available.
    ///
    /// Stored as a plain string so this type remains FFI-friendly and
    /// unconditionally available without the `chrono` optional dep.
    /// DOCX populates this from the `w:date` attribute (e.g.
    /// `"2024-03-15T10:30:00Z"`).
    #[php(prop, name = "timestamp")]
    pub timestamp: Option<String>,
    /// Semantic kind of this revision.
    #[php(prop, name = "kind")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub kind: String,
    /// Best-effort document location for this revision.
    ///
    /// Resolution is format-dependent and may be `None` when the location
    /// cannot be determined (e.g. changes inside table cells before
    /// table-cell anchor support is added).
    pub anchor: Option<RevisionAnchor>,
    /// The content changes that make up this revision.
    pub delta: RevisionDelta,
}

#[php_impl]
impl DocumentRevision {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(revisionId: String, author: Option<String>, timestamp: Option<String>, kind: String) -> Self {
        Self {
            revision_id: revisionId,
            author,
            timestamp,
            kind,
            anchor: Default::default(),
            delta: Default::default(),
        }
    }

    pub fn get_revision_id(&self) -> String {
        self.revision_id.clone()
    }

    pub fn get_author(&self) -> Option<String> {
        self.author.clone()
    }

    pub fn get_timestamp(&self) -> Option<String> {
        self.timestamp.clone()
    }

    pub fn get_kind(&self) -> String {
        self.kind.clone()
    }

    pub fn get_anchor(&self) -> Option<RevisionAnchor> {
        self.anchor.clone().map(Into::into)
    }

    pub fn get_delta(&self) -> RevisionDelta {
        self.delta.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\RevisionDelta")]
#[serde(default, rename_all = "camelCase")]
pub struct RevisionDelta {
    /// Line-level content changes for this revision.
    pub content: Vec<DiffLine>,
    /// Cell-level table changes for this revision.
    pub table_changes: Vec<CellChange>,
}

impl Default for RevisionDelta {
    fn default() -> Self {
        <kreuzberg::RevisionDelta as Default>::default().into()
    }
}

#[php_impl]
impl RevisionDelta {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_content(&self) -> Vec<DiffLine> {
        self.content.clone()
    }

    pub fn get_table_changes(&self) -> Vec<CellChange> {
        self.table_changes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\Table")]
#[serde(default, rename_all = "camelCase")]
pub struct Table {
    /// Table cells as a 2D vector (rows × columns)
    pub cells: Vec<Vec<String>>,
    /// Markdown representation of the table
    #[php(prop, name = "markdown")]
    pub markdown: String,
    /// Page number where the table was found (1-indexed)
    #[php(prop, name = "pageNumber")]
    pub page_number: u32,
    /// Bounding box of the table on the page (PDF coordinates: x0=left, y0=bottom, x1=right, y1=top).
    /// Only populated for PDF-extracted tables when position data is available.
    pub bounding_box: Option<BoundingBox>,
}

impl Default for Table {
    fn default() -> Self {
        <kreuzberg::Table as Default>::default().into()
    }
}

#[php_impl]
impl Table {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(cells: Vec<Vec<String>>, markdown: String, pageNumber: u32) -> Self {
        Self {
            cells,
            markdown,
            page_number: pageNumber,
            bounding_box: Default::default(),
        }
    }

    pub fn get_cells(&self) -> Vec<Vec<String>> {
        self.cells.clone()
    }

    pub fn get_markdown(&self) -> String {
        self.markdown.clone()
    }

    pub fn get_page_number(&self) -> u32 {
        self.page_number.clone()
    }

    pub fn get_bounding_box(&self) -> Option<BoundingBox> {
        self.bounding_box.clone().map(Into::into)
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\TableCell")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct TableCell {
    /// Cell content as text
    #[php(prop, name = "content")]
    pub content: String,
    /// Row span (number of rows this cell spans)
    #[php(prop, name = "rowSpan")]
    pub row_span: u32,
    /// Column span (number of columns this cell spans)
    #[php(prop, name = "colSpan")]
    pub col_span: u32,
    /// Whether this is a header cell
    #[php(prop, name = "isHeader")]
    pub is_header: bool,
}

impl Default for TableCell {
    fn default() -> Self {
        <kreuzberg::TableCell as Default>::default().into()
    }
}

#[php_impl]
impl TableCell {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(content: String, rowSpan: u32, colSpan: u32, isHeader: bool) -> Self {
        Self {
            content,
            row_span: rowSpan,
            col_span: colSpan,
            is_header: isHeader,
        }
    }

    pub fn get_content(&self) -> String {
        self.content.clone()
    }

    pub fn get_row_span(&self) -> u32 {
        self.row_span.clone()
    }

    pub fn get_col_span(&self) -> u32 {
        self.col_span.clone()
    }

    pub fn get_is_header(&self) -> bool {
        self.is_header.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ExtractedUri")]
#[serde(default, rename_all = "camelCase")]
pub struct ExtractedUri {
    /// The URL or path string.
    #[php(prop, name = "url")]
    pub url: String,
    /// Optional display text / label for the link.
    #[php(prop, name = "label")]
    pub label: Option<String>,
    /// Optional page number where the URI was found (1-indexed).
    #[php(prop, name = "page")]
    pub page: Option<u32>,
    /// Semantic classification of the URI.
    #[php(prop, name = "kind")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub kind: String,
}

#[php_impl]
impl ExtractedUri {
    #[php(constructor)]
    pub fn new(url: String, label: Option<String>, page: Option<u32>, kind: String) -> Self {
        Self { url, label, page, kind }
    }

    pub fn get_url(&self) -> String {
        self.url.clone()
    }

    pub fn get_label(&self) -> Option<String> {
        self.label.clone()
    }

    pub fn get_page(&self) -> Option<u32> {
        self.page.clone()
    }

    pub fn get_kind(&self) -> String {
        self.kind.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DetectResponse")]
#[serde(default, rename_all = "camelCase")]
pub struct DetectResponse {
    /// Detected MIME type
    #[php(prop, name = "mimeType")]
    pub mime_type: String,
    /// Original filename (if provided)
    #[php(prop, name = "filename")]
    pub filename: Option<String>,
}

#[php_impl]
impl DetectResponse {
    #[php(constructor)]
    pub fn new(mimeType: String, filename: Option<String>) -> Self {
        Self {
            mime_type: mimeType,
            filename,
        }
    }

    pub fn get_mime_type(&self) -> String {
        self.mime_type.clone()
    }

    pub fn get_filename(&self) -> Option<String> {
        self.filename.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\DiffOptions")]
#[serde(default, rename_all = "camelCase")]
pub struct DiffOptions {
    /// Include metadata changes in the diff. Default: `true`.
    #[php(prop, name = "includeMetadata")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub include_metadata: bool,
    /// Include embedded-children changes in the diff. Default: `true`.
    #[php(prop, name = "includeEmbedded")]
    #[serde(default = "crate::serde_defaults::bool_true")]
    pub include_embedded: bool,
    /// Truncate content to this many characters before diffing.
    ///
    /// Useful for very large documents where only the first N characters matter.
    /// `None` means no truncation.
    #[php(prop, name = "maxContentChars")]
    pub max_content_chars: Option<i64>,
}

impl Default for DiffOptions {
    fn default() -> Self {
        <kreuzberg::DiffOptions as Default>::default().into()
    }
}

#[php_impl]
impl DiffOptions {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(includeMetadata: bool, includeEmbedded: bool, maxContentChars: Option<i64>) -> Self {
        Self {
            include_metadata: includeMetadata,
            include_embedded: includeEmbedded,
            max_content_chars: maxContentChars,
        }
    }

    pub fn get_include_metadata(&self) -> bool {
        self.include_metadata.clone()
    }

    pub fn get_include_embedded(&self) -> bool {
        self.include_embedded.clone()
    }

    pub fn get_max_content_chars(&self) -> Option<i64> {
        self.max_content_chars.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> DiffOptions {
        kreuzberg::DiffOptions::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ExtractionDiff")]
#[serde(default, rename_all = "camelCase")]
pub struct ExtractionDiff {
    /// Unified-diff hunks for the `content` field.
    ///
    /// Empty when the content is identical.
    pub content_diff: Vec<DiffHunk>,
    /// Tables present in `b` but not in `a` (by index position, excess right-side tables).
    pub tables_added: Vec<Table>,
    /// Tables present in `a` but not in `b` (by index position, excess left-side tables).
    pub tables_removed: Vec<Table>,
    /// Cell-level changes for table pairs that share the same index and dimensions.
    pub tables_changed: Vec<TableDiff>,
    /// Metadata difference, encoded as a JSON object with three top-level keys:
    /// `added` (keys present in `b` but not `a`), `removed` (keys present in `a`
    /// but not `b`), and `changed` (keys whose values differ — each entry is
    /// `{ "from": <value-in-a>, "to": <value-in-b> }`).
    ///
    /// This is NOT RFC 6902 JSON Patch — we deliberately chose a flatter shape
    /// to avoid pulling in a json-patch crate. If you need RFC 6902 semantics
    /// (with JSON Pointer paths) feed `a.metadata` and `b.metadata` to your
    /// preferred json-patch impl directly.
    pub metadata_changed: serde_json::Value,
    /// Changes to embedded archive children.
    pub embedded_changes: EmbeddedChanges,
}

#[php_impl]
impl ExtractionDiff {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_content_diff(&self) -> Vec<DiffHunk> {
        self.content_diff.clone()
    }

    pub fn get_tables_added(&self) -> Vec<Table> {
        self.tables_added.clone()
    }

    pub fn get_tables_removed(&self) -> Vec<Table> {
        self.tables_removed.clone()
    }

    pub fn get_tables_changed(&self) -> Vec<TableDiff> {
        self.tables_changed.clone()
    }

    pub fn get_metadata_changed(&self) -> Option<String> {
        serde_json::to_string(&self.metadata_changed).ok()
    }

    pub fn get_embedded_changes(&self) -> EmbeddedChanges {
        self.embedded_changes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DiffHunk")]
#[serde(default, rename_all = "camelCase")]
pub struct DiffHunk {
    /// Starting line number in the old content (0-indexed).
    #[php(prop, name = "fromLine")]
    pub from_line: i64,
    /// Number of lines from the old content in this hunk.
    #[php(prop, name = "fromCount")]
    pub from_count: i64,
    /// Starting line number in the new content (0-indexed).
    #[php(prop, name = "toLine")]
    pub to_line: i64,
    /// Number of lines from the new content in this hunk.
    #[php(prop, name = "toCount")]
    pub to_count: i64,
    /// Lines that make up this hunk.
    pub lines: Vec<DiffLine>,
}

#[php_impl]
impl DiffHunk {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(fromLine: i64, fromCount: i64, toLine: i64, toCount: i64) -> Self {
        Self {
            from_line: fromLine,
            from_count: fromCount,
            to_line: toLine,
            to_count: toCount,
            lines: Default::default(),
        }
    }

    pub fn get_from_line(&self) -> i64 {
        self.from_line.clone()
    }

    pub fn get_from_count(&self) -> i64 {
        self.from_count.clone()
    }

    pub fn get_to_line(&self) -> i64 {
        self.to_line.clone()
    }

    pub fn get_to_count(&self) -> i64 {
        self.to_count.clone()
    }

    pub fn get_lines(&self) -> Vec<DiffLine> {
        self.lines.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\TableDiff")]
#[serde(default, rename_all = "camelCase")]
pub struct TableDiff {
    /// Zero-based index of the table in both `a.tables` and `b.tables`.
    #[php(prop, name = "fromIndex")]
    pub from_index: i64,
    /// Zero-based index in `b.tables` (equal to `from_index` for same-dimension tables).
    #[php(prop, name = "toIndex")]
    pub to_index: i64,
    /// Cell-level changes within the table.
    pub cell_changes: Vec<CellChange>,
}

#[php_impl]
impl TableDiff {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(fromIndex: i64, toIndex: i64) -> Self {
        Self {
            from_index: fromIndex,
            to_index: toIndex,
            cell_changes: Default::default(),
        }
    }

    pub fn get_from_index(&self) -> i64 {
        self.from_index.clone()
    }

    pub fn get_to_index(&self) -> i64 {
        self.to_index.clone()
    }

    pub fn get_cell_changes(&self) -> Vec<CellChange> {
        self.cell_changes.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\EmbeddedChanges")]
#[serde(default, rename_all = "camelCase")]
pub struct EmbeddedChanges {
    /// Children present in `b` but not in `a` (matched by `path`).
    pub added: Vec<ArchiveEntry>,
    /// Children present in `a` but not in `b` (matched by `path`).
    pub removed: Vec<ArchiveEntry>,
    /// Children present in both but with differing content (matched by `path`).
    ///
    /// Each entry holds the diff of the nested `ExtractionResult`.
    pub changed: Vec<EmbeddedDiff>,
}

#[php_impl]
impl EmbeddedChanges {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_added(&self) -> Vec<ArchiveEntry> {
        self.added.clone()
    }

    pub fn get_removed(&self) -> Vec<ArchiveEntry> {
        self.removed.clone()
    }

    pub fn get_changed(&self) -> Vec<EmbeddedDiff> {
        self.changed.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\EmbeddedDiff")]
#[serde(default, rename_all = "camelCase")]
pub struct EmbeddedDiff {
    /// Archive-relative path identifying this entry.
    #[php(prop, name = "path")]
    pub path: String,
    /// The recursive diff of the entry's extraction result.
    pub diff: ExtractionDiff,
}

#[php_impl]
impl EmbeddedDiff {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(path: String) -> Self {
        Self {
            path,
            diff: Default::default(),
        }
    }

    pub fn get_path(&self) -> String {
        self.path.clone()
    }

    pub fn get_diff(&self) -> ExtractionDiff {
        self.diff.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\EmbeddingPreset")]
#[serde(default, rename_all = "camelCase")]
pub struct EmbeddingPreset {
    #[php(prop, name = "name")]
    pub name: String,
    #[php(prop, name = "chunkSize")]
    pub chunk_size: i64,
    #[php(prop, name = "overlap")]
    pub overlap: i64,
    /// HuggingFace repository name for the model.
    #[php(prop, name = "modelRepo")]
    pub model_repo: String,
    /// Pooling strategy: "cls" or "mean".
    #[php(prop, name = "pooling")]
    pub pooling: String,
    /// Path to the ONNX model file within the repo.
    #[php(prop, name = "modelFile")]
    pub model_file: String,
    #[php(prop, name = "dimensions")]
    pub dimensions: i64,
    #[php(prop, name = "description")]
    pub description: String,
}

#[php_impl]
impl EmbeddingPreset {
    #[php(constructor)]
    pub fn new(
        name: String,
        chunkSize: i64,
        overlap: i64,
        modelRepo: String,
        pooling: String,
        modelFile: String,
        dimensions: i64,
        description: String,
    ) -> Self {
        Self {
            name,
            chunk_size: chunkSize,
            overlap,
            model_repo: modelRepo,
            pooling,
            model_file: modelFile,
            dimensions,
            description,
        }
    }

    pub fn get_name(&self) -> String {
        self.name.clone()
    }

    pub fn get_chunk_size(&self) -> i64 {
        self.chunk_size.clone()
    }

    pub fn get_overlap(&self) -> i64 {
        self.overlap.clone()
    }

    pub fn get_model_repo(&self) -> String {
        self.model_repo.clone()
    }

    pub fn get_pooling(&self) -> String {
        self.pooling.clone()
    }

    pub fn get_model_file(&self) -> String {
        self.model_file.clone()
    }

    pub fn get_dimensions(&self) -> i64 {
        self.dimensions.clone()
    }

    pub fn get_description(&self) -> String {
        self.description.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\YakeParams")]
#[serde(default, rename_all = "camelCase")]
pub struct YakeParams {
    /// Window size for co-occurrence analysis (default: 2).
    ///
    /// Controls the context window for computing co-occurrence statistics.
    #[php(prop, name = "windowSize")]
    pub window_size: i64,
}

impl Default for YakeParams {
    fn default() -> Self {
        <kreuzberg::YakeParams as Default>::default().into()
    }
}

#[php_impl]
impl YakeParams {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(windowSize: i64) -> Self {
        Self {
            window_size: windowSize,
        }
    }

    pub fn get_window_size(&self) -> i64 {
        self.window_size.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> YakeParams {
        kreuzberg::YakeParams::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\RakeParams")]
#[serde(default, rename_all = "camelCase")]
pub struct RakeParams {
    /// Minimum word length to consider (default: 1).
    #[php(prop, name = "minWordLength")]
    pub min_word_length: i64,
    /// Maximum words in a keyword phrase (default: 3).
    #[php(prop, name = "maxWordsPerPhrase")]
    pub max_words_per_phrase: i64,
}

impl Default for RakeParams {
    fn default() -> Self {
        <kreuzberg::RakeParams as Default>::default().into()
    }
}

#[php_impl]
impl RakeParams {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(minWordLength: i64, maxWordsPerPhrase: i64) -> Self {
        Self {
            min_word_length: minWordLength,
            max_words_per_phrase: maxWordsPerPhrase,
        }
    }

    pub fn get_min_word_length(&self) -> i64 {
        self.min_word_length.clone()
    }

    pub fn get_max_words_per_phrase(&self) -> i64 {
        self.max_words_per_phrase.clone()
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> RakeParams {
        kreuzberg::RakeParams::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\KeywordConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct KeywordConfig {
    /// Algorithm to use for extraction.
    #[php(prop, name = "algorithm")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub algorithm: String,
    /// Maximum number of keywords to extract (default: 10).
    #[php(prop, name = "maxKeywords")]
    pub max_keywords: i64,
    /// Minimum score threshold (0.0-1.0, default: 0.0).
    ///
    /// Keywords with scores below this threshold are filtered out.
    /// Note: Score ranges differ between algorithms.
    #[php(prop, name = "minScore")]
    pub min_score: f32,
    /// N-gram range for keyword extraction (min, max).
    ///
    /// (1, 1) = unigrams only
    /// (1, 2) = unigrams and bigrams
    /// (1, 3) = unigrams, bigrams, and trigrams (default)
    #[php(prop, name = "ngramRange")]
    #[serde(skip)]
    pub ngram_range: Vec<i64>,
    /// Language code for stopword filtering (e.g., "en", "de", "fr").
    ///
    /// If None, no stopword filtering is applied.
    #[php(prop, name = "language")]
    pub language: Option<String>,
    /// YAKE-specific tuning parameters.
    pub yake_params: Option<YakeParams>,
    /// RAKE-specific tuning parameters.
    pub rake_params: Option<RakeParams>,
}

impl Default for KeywordConfig {
    fn default() -> Self {
        <kreuzberg::KeywordConfig as Default>::default().into()
    }
}

#[php_impl]
impl KeywordConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        algorithm: String,
        maxKeywords: i64,
        minScore: f32,
        ngramRange: Vec<i64>,
        language: Option<String>,
    ) -> Self {
        Self {
            algorithm,
            max_keywords: maxKeywords,
            min_score: minScore,
            ngram_range: ngramRange,
            language,
            yake_params: Default::default(),
            rake_params: Default::default(),
        }
    }

    pub fn get_algorithm(&self) -> String {
        self.algorithm.clone()
    }

    pub fn get_max_keywords(&self) -> i64 {
        self.max_keywords.clone()
    }

    pub fn get_min_score(&self) -> f32 {
        self.min_score.clone()
    }

    pub fn get_ngram_range(&self) -> Vec<i64> {
        self.ngram_range.clone()
    }

    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    pub fn get_yake_params(&self) -> Option<YakeParams> {
        self.yake_params.clone().map(Into::into)
    }

    pub fn get_rake_params(&self) -> Option<RakeParams> {
        self.rake_params.clone().map(Into::into)
    }

    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> KeywordConfig {
        kreuzberg::KeywordConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\Keyword")]
#[serde(default, rename_all = "camelCase")]
pub struct Keyword {
    /// The keyword text.
    #[php(prop, name = "text")]
    pub text: String,
    /// Relevance score (higher is better, algorithm-specific range).
    #[php(prop, name = "score")]
    pub score: f32,
    /// Algorithm that extracted this keyword.
    #[php(prop, name = "algorithm")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub algorithm: String,
    /// Optional positions where keyword appears in text (character offsets).
    #[php(prop, name = "positions")]
    pub positions: Option<Vec<i64>>,
}

#[php_impl]
impl Keyword {
    #[php(constructor)]
    pub fn new(text: String, score: f32, algorithm: String, positions: Option<Vec<i64>>) -> Self {
        Self {
            text,
            score,
            algorithm,
            positions,
        }
    }

    pub fn get_text(&self) -> String {
        self.text.clone()
    }

    pub fn get_score(&self) -> f32 {
        self.score.clone()
    }

    pub fn get_algorithm(&self) -> String {
        self.algorithm.clone()
    }

    pub fn get_positions(&self) -> Option<Vec<i64>> {
        self.positions.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PaddleOcrConfig")]
#[serde(default, rename_all = "camelCase")]
pub struct PaddleOcrConfig {
    /// Language code (e.g., "en", "ch", "jpn", "kor", "deu", "fra")
    #[php(prop, name = "language")]
    pub language: String,
    /// Optional custom cache directory for model files
    #[php(prop, name = "cacheDir")]
    pub cache_dir: Option<String>,
    /// Enable angle classification for rotated text (default: false).
    /// Can misfire on short text regions, rotating crops incorrectly before recognition.
    #[php(prop, name = "useAngleCls")]
    pub use_angle_cls: bool,
    /// Enable table structure detection (default: false)
    #[php(prop, name = "enableTableDetection")]
    pub enable_table_detection: bool,
    /// Database threshold for text detection (default: 0.3)
    /// Range: 0.0-1.0, higher values require more confident detections
    #[php(prop, name = "detDbThresh")]
    pub det_db_thresh: f32,
    /// Box threshold for text bounding box refinement (default: 0.5)
    /// Range: 0.0-1.0
    #[php(prop, name = "detDbBoxThresh")]
    pub det_db_box_thresh: f32,
    /// Unclip ratio for expanding text bounding boxes (default: 1.6)
    /// Controls the expansion of detected text regions
    #[php(prop, name = "detDbUnclipRatio")]
    pub det_db_unclip_ratio: f32,
    /// Maximum side length for detection image (default: 960)
    /// Larger images may be resized to this limit for faster inference
    #[php(prop, name = "detLimitSideLen")]
    pub det_limit_side_len: u32,
    /// Batch size for recognition inference (default: 6)
    /// Number of text regions to process simultaneously
    #[php(prop, name = "recBatchNum")]
    pub rec_batch_num: u32,
    /// Padding in pixels added around the image before detection (default: 10).
    /// Large values can include surrounding content like table gridlines.
    #[php(prop, name = "padding")]
    pub padding: u32,
    /// Minimum recognition confidence score for text lines (default: 0.5).
    /// Text regions with recognition confidence below this threshold are discarded.
    /// Matches PaddleOCR Python's `drop_score` parameter.
    /// Range: 0.0-1.0
    #[php(prop, name = "dropScore")]
    pub drop_score: f32,
    /// Model tier controlling detection/recognition model size and accuracy trade-off.
    /// - `"mobile"` (default): Lightweight models (~4.5MB detection, ~16.5MB recognition), fast download and inference
    /// - `"server"`: Large, high-accuracy models (~88MB detection, ~84MB recognition), best for GPU or complex documents
    #[php(prop, name = "modelTier")]
    pub model_tier: String,
}

impl Default for PaddleOcrConfig {
    fn default() -> Self {
        <kreuzberg::PaddleOcrConfig as Default>::default().into()
    }
}

#[php_impl]
impl PaddleOcrConfig {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(
        language: String,
        cacheDir: Option<String>,
        useAngleCls: bool,
        enableTableDetection: bool,
        detDbThresh: f32,
        detDbBoxThresh: f32,
        detDbUnclipRatio: f32,
        detLimitSideLen: u32,
        recBatchNum: u32,
        padding: u32,
        dropScore: f32,
        modelTier: String,
    ) -> Self {
        Self {
            language,
            cache_dir: cacheDir,
            use_angle_cls: useAngleCls,
            enable_table_detection: enableTableDetection,
            det_db_thresh: detDbThresh,
            det_db_box_thresh: detDbBoxThresh,
            det_db_unclip_ratio: detDbUnclipRatio,
            det_limit_side_len: detLimitSideLen,
            rec_batch_num: recBatchNum,
            padding,
            drop_score: dropScore,
            model_tier: modelTier,
        }
    }

    pub fn get_language(&self) -> String {
        self.language.clone()
    }

    pub fn get_cache_dir(&self) -> Option<String> {
        self.cache_dir.clone()
    }

    pub fn get_use_angle_cls(&self) -> bool {
        self.use_angle_cls.clone()
    }

    pub fn get_enable_table_detection(&self) -> bool {
        self.enable_table_detection.clone()
    }

    pub fn get_det_db_thresh(&self) -> f32 {
        self.det_db_thresh.clone()
    }

    pub fn get_det_db_box_thresh(&self) -> f32 {
        self.det_db_box_thresh.clone()
    }

    pub fn get_det_db_unclip_ratio(&self) -> f32 {
        self.det_db_unclip_ratio.clone()
    }

    pub fn get_det_limit_side_len(&self) -> u32 {
        self.det_limit_side_len.clone()
    }

    pub fn get_rec_batch_num(&self) -> u32 {
        self.rec_batch_num.clone()
    }

    pub fn get_padding(&self) -> u32 {
        self.padding.clone()
    }

    pub fn get_drop_score(&self) -> f32 {
        self.drop_score.clone()
    }

    pub fn get_model_tier(&self) -> String {
        self.model_tier.clone()
    }

    pub fn with_cache_dir(&self, path: String) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_cache_dir(std::path::PathBuf::from(path)).into()
    }

    pub fn with_table_detection(&self, enable: bool) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_table_detection(enable).into()
    }

    pub fn with_angle_cls(&self, enable: bool) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_angle_cls(enable).into()
    }

    pub fn with_det_db_thresh(&self, threshold: f32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_det_db_thresh(threshold).into()
    }

    pub fn with_det_db_box_thresh(&self, threshold: f32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_det_db_box_thresh(threshold).into()
    }

    pub fn with_det_db_unclip_ratio(&self, ratio: f32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_det_db_unclip_ratio(ratio).into()
    }

    pub fn with_det_limit_side_len(&self, length: u32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_det_limit_side_len(length).into()
    }

    pub fn with_rec_batch_num(&self, batchSize: u32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_rec_batch_num(batchSize).into()
    }

    pub fn with_drop_score(&self, score: f32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_drop_score(score).into()
    }

    pub fn with_padding(&self, padding: u32) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_padding(padding).into()
    }

    pub fn with_model_tier(&self, tier: String) -> PaddleOcrConfig {
        let core_self = kreuzberg::PaddleOcrConfig {
            language: self.language.clone(),
            cache_dir: self.cache_dir.clone().map(Into::into),
            use_angle_cls: self.use_angle_cls,
            enable_table_detection: self.enable_table_detection,
            det_db_thresh: self.det_db_thresh,
            det_db_box_thresh: self.det_db_box_thresh,
            det_db_unclip_ratio: self.det_db_unclip_ratio,
            det_limit_side_len: self.det_limit_side_len,
            rec_batch_num: self.rec_batch_num,
            padding: self.padding,
            drop_score: self.drop_score,
            model_tier: self.model_tier.clone(),
        };
        core_self.with_model_tier(tier).into()
    }

    /**
     * Creates a default configuration with English language support.
     */
    #[allow(clippy::should_implement_trait)]
    #[php(name = "default")]
    pub fn default() -> PaddleOcrConfig {
        kreuzberg::PaddleOcrConfig::default().into()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\ModelPaths")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct ModelPaths {
    /// Path to the detection model directory.
    #[php(prop, name = "detModel")]
    pub det_model: String,
    /// Path to the classification model directory.
    #[php(prop, name = "clsModel")]
    pub cls_model: String,
    /// Path to the recognition model directory.
    #[php(prop, name = "recModel")]
    pub rec_model: String,
    /// Path to the character dictionary file.
    #[php(prop, name = "dictFile")]
    pub dict_file: String,
}

#[php_impl]
impl ModelPaths {
    #[php(constructor)]
    pub fn new(detModel: String, clsModel: String, recModel: String, dictFile: String) -> Self {
        Self {
            det_model: detModel,
            cls_model: clsModel,
            rec_model: recModel,
            dict_file: dictFile,
        }
    }

    pub fn get_det_model(&self) -> String {
        self.det_model.clone()
    }

    pub fn get_cls_model(&self) -> String {
        self.cls_model.clone()
    }

    pub fn get_rec_model(&self) -> String {
        self.rec_model.clone()
    }

    pub fn get_dict_file(&self) -> String {
        self.dict_file.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\OrientationResult")]
#[serde(default, rename_all = "camelCase")]
pub struct OrientationResult {
    /// Detected orientation in degrees (0, 90, 180, or 270).
    #[php(prop, name = "degrees")]
    pub degrees: u32,
    /// Confidence score (0.0-1.0).
    #[php(prop, name = "confidence")]
    pub confidence: f32,
}

#[php_impl]
impl OrientationResult {
    #[php(constructor)]
    pub fn new(degrees: u32, confidence: f32) -> Self {
        Self { degrees, confidence }
    }

    pub fn get_degrees(&self) -> u32 {
        self.degrees.clone()
    }

    pub fn get_confidence(&self) -> f32 {
        self.confidence.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\BBox")]
#[serde(default, rename_all = "camelCase")]
#[allow(clippy::similar_names)]
pub struct BBox {
    #[php(prop, name = "x1")]
    pub x1: f32,
    #[php(prop, name = "y1")]
    pub y1: f32,
    #[php(prop, name = "x2")]
    pub x2: f32,
    #[php(prop, name = "y2")]
    pub y2: f32,
}

#[php_impl]
impl BBox {
    #[php(constructor)]
    pub fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
        Self { x1, y1, x2, y2 }
    }

    pub fn get_x1(&self) -> f32 {
        self.x1.clone()
    }

    pub fn get_y1(&self) -> f32 {
        self.y1.clone()
    }

    pub fn get_x2(&self) -> f32 {
        self.x2.clone()
    }

    pub fn get_y2(&self) -> f32 {
        self.y2.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\LayoutDetection")]
#[serde(default, rename_all = "camelCase")]
pub struct LayoutDetection {
    #[php(prop, name = "className")]
    #[serde(skip_serializing_if = "String::is_empty")]
    pub class_name: String,
    #[php(prop, name = "confidence")]
    pub confidence: f32,
    pub bbox: BBox,
}

#[php_impl]
impl LayoutDetection {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(className: String, confidence: f32) -> Self {
        Self {
            class_name: className,
            confidence,
            bbox: Default::default(),
        }
    }

    pub fn get_class_name(&self) -> String {
        self.class_name.clone()
    }

    pub fn get_confidence(&self) -> f32 {
        self.confidence.clone()
    }

    pub fn get_bbox(&self) -> BBox {
        self.bbox.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\RecognizedTable")]
#[serde(default, rename_all = "camelCase")]
pub struct RecognizedTable {
    /// Detection bbox that this table corresponds to (for matching).
    pub detection_bbox: BBox,
    /// Table cells as a 2D vector (rows × columns).
    pub cells: Vec<Vec<String>>,
    /// Rendered markdown table.
    #[php(prop, name = "markdown")]
    pub markdown: String,
}

#[php_impl]
impl RecognizedTable {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(cells: Vec<Vec<String>>, markdown: String) -> Self {
        Self {
            detection_bbox: Default::default(),
            cells,
            markdown,
        }
    }

    pub fn get_detection_bbox(&self) -> BBox {
        self.detection_bbox.clone()
    }

    pub fn get_cells(&self) -> Vec<Vec<String>> {
        self.cells.clone()
    }

    pub fn get_markdown(&self) -> String {
        self.markdown.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\DetectionResult")]
#[serde(default, rename_all = "camelCase")]
pub struct DetectionResult {
    #[php(prop, name = "pageWidth")]
    pub page_width: u32,
    #[php(prop, name = "pageHeight")]
    pub page_height: u32,
    pub detections: Vec<LayoutDetection>,
}

#[php_impl]
impl DetectionResult {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(pageWidth: u32, pageHeight: u32) -> Self {
        Self {
            page_width: pageWidth,
            page_height: pageHeight,
            detections: Default::default(),
        }
    }

    pub fn get_page_width(&self) -> u32 {
        self.page_width.clone()
    }

    pub fn get_page_height(&self) -> u32 {
        self.page_height.clone()
    }

    pub fn get_detections(&self) -> Vec<LayoutDetection> {
        self.detections.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize, Default)]
#[php_class]
#[php(name = "Kreuzberg\\EmbeddedFile")]
#[serde(default, rename_all = "camelCase")]
pub struct EmbeddedFile {
    /// The filename as stored in the PDF name tree.
    #[php(prop, name = "name")]
    pub name: String,
    /// Raw file bytes from the embedded stream (already decompressed by lopdf).
    pub data: Vec<u8>,
    /// Compressed byte count of the original stream (before decompression).
    ///
    /// Used by callers to compute the decompression ratio and detect zip-bomb-style
    /// attacks that embed a tiny compressed stream expanding to gigabytes of data.
    #[php(prop, name = "compressedSize")]
    pub compressed_size: i64,
    /// MIME type if specified in the filespec, otherwise `None`.
    #[php(prop, name = "mimeType")]
    pub mime_type: Option<String>,
}

#[php_impl]
impl EmbeddedFile {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(constructor)]
    pub fn new(name: String, data: PhpBytes, compressedSize: i64, mimeType: Option<String>) -> Self {
        Self {
            name,
            data: data.0,
            compressed_size: compressedSize,
            mime_type: mimeType,
        }
    }

    pub fn get_name(&self) -> String {
        self.name.clone()
    }

    pub fn get_data(&self) -> Vec<u8> {
        self.data.clone()
    }

    pub fn get_compressed_size(&self) -> i64 {
        self.compressed_size.clone()
    }

    pub fn get_mime_type(&self) -> Option<String> {
        self.mime_type.clone()
    }
}

#[derive(Clone, serde::Serialize, serde::Deserialize)]
#[php_class]
#[php(name = "Kreuzberg\\PdfMetadata")]
#[serde(default, rename_all = "camelCase")]
pub struct PdfMetadata {
    /// PDF version (e.g., "1.7", "2.0")
    #[php(prop, name = "pdfVersion")]
    pub pdf_version: Option<String>,
    /// PDF producer (application that created the PDF)
    #[php(prop, name = "producer")]
    pub producer: Option<String>,
    /// Whether the PDF is encrypted/password-protected
    #[php(prop, name = "isEncrypted")]
    pub is_encrypted: Option<bool>,
    /// First page width in points (1/72 inch)
    #[php(prop, name = "width")]
    pub width: Option<i64>,
    /// First page height in points (1/72 inch)
    #[php(prop, name = "height")]
    pub height: Option<i64>,
    /// Total number of pages in the PDF document
    #[php(prop, name = "pageCount")]
    pub page_count: Option<u32>,
}

impl Default for PdfMetadata {
    fn default() -> Self {
        <kreuzberg::pdf::metadata::PdfMetadata as Default>::default().into()
    }
}

#[php_impl]
impl PdfMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    pub fn get_pdf_version(&self) -> Option<String> {
        self.pdf_version.clone()
    }

    pub fn get_producer(&self) -> Option<String> {
        self.producer.clone()
    }

    pub fn get_is_encrypted(&self) -> Option<bool> {
        self.is_encrypted.clone()
    }

    pub fn get_width(&self) -> Option<i64> {
        self.width.clone()
    }

    pub fn get_height(&self) -> Option<i64> {
        self.height.clone()
    }

    pub fn get_page_count(&self) -> Option<u32> {
        self.page_count.clone()
    }
}

// ExecutionProviderType enum values
pub const EXECUTIONPROVIDERTYPE_AUTO: &str = "Auto";
pub const EXECUTIONPROVIDERTYPE_CPU: &str = "Cpu";
pub const EXECUTIONPROVIDERTYPE_COREML: &str = "CoreMl";
pub const EXECUTIONPROVIDERTYPE_CUDA: &str = "Cuda";
pub const EXECUTIONPROVIDERTYPE_TENSORRT: &str = "TensorRt";

// OutputFormat enum values
pub const OUTPUTFORMAT_PLAIN: &str = "Plain";
pub const OUTPUTFORMAT_MARKDOWN: &str = "Markdown";
pub const OUTPUTFORMAT_DJOT: &str = "Djot";
pub const OUTPUTFORMAT_HTML: &str = "Html";
pub const OUTPUTFORMAT_JSON: &str = "Json";
pub const OUTPUTFORMAT_STRUCTURED: &str = "Structured";
pub const OUTPUTFORMAT_CUSTOM: &str = "Custom";

// HtmlTheme enum values
pub const HTMLTHEME_DEFAULT: &str = "Default";
pub const HTMLTHEME_GITHUB: &str = "GitHub";
pub const HTMLTHEME_DARK: &str = "Dark";
pub const HTMLTHEME_LIGHT: &str = "Light";
pub const HTMLTHEME_UNSTYLED: &str = "Unstyled";

// TableModel enum values
pub const TABLEMODEL_TATR: &str = "Tatr";
pub const TABLEMODEL_SLANETWIRED: &str = "SlanetWired";
pub const TABLEMODEL_SLANETWIRELESS: &str = "SlanetWireless";
pub const TABLEMODEL_SLANETPLUS: &str = "SlanetPlus";
pub const TABLEMODEL_SLANETAUTO: &str = "SlanetAuto";
pub const TABLEMODEL_DISABLED: &str = "Disabled";

// ChunkerType enum values
pub const CHUNKERTYPE_TEXT: &str = "Text";
pub const CHUNKERTYPE_MARKDOWN: &str = "Markdown";
pub const CHUNKERTYPE_YAML: &str = "Yaml";
pub const CHUNKERTYPE_SEMANTIC: &str = "Semantic";

#[php_class]
#[php(name = "Kreuzberg\\ChunkSizing")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ChunkSizing {
    #[php(prop, name = "type")]
    #[serde(rename = "type")]
    pub type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cache_dir: Option<String>,
}

#[php_impl]
impl ChunkSizing {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_type_tag(&self) -> String {
        self.type_tag.clone()
    }

    #[php(getter)]
    pub fn get_model(&self) -> Option<String> {
        self.model.clone()
    }

    #[php(getter)]
    pub fn get_cache_dir(&self) -> Option<String> {
        self.cache_dir.clone()
    }
}

#[php_class]
#[php(name = "Kreuzberg\\EmbeddingModelType")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct EmbeddingModelType {
    #[php(prop, name = "type")]
    #[serde(rename = "type")]
    pub type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dimensions: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub llm: Option<LlmConfig>,
}

#[php_impl]
impl EmbeddingModelType {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_type_tag(&self) -> String {
        self.type_tag.clone()
    }

    #[php(getter)]
    pub fn get_name(&self) -> Option<String> {
        self.name.clone()
    }

    #[php(getter)]
    pub fn get_model_id(&self) -> Option<String> {
        self.model_id.clone()
    }

    #[php(getter)]
    pub fn get_dimensions(&self) -> Option<i64> {
        self.dimensions
    }

    #[php(getter)]
    pub fn get_llm(&self) -> Option<LlmConfig> {
        self.llm.clone()
    }
}

// CodeContentMode enum values
pub const CODECONTENTMODE_CHUNKS: &str = "Chunks";
pub const CODECONTENTMODE_RAW: &str = "Raw";
pub const CODECONTENTMODE_STRUCTURE: &str = "Structure";

// ListType enum values
pub const LISTTYPE_BULLET: &str = "Bullet";
pub const LISTTYPE_NUMBERED: &str = "Numbered";
pub const LISTTYPE_LETTERED: &str = "Lettered";
pub const LISTTYPE_INDENTED: &str = "Indented";

// OcrBackendType enum values
pub const OCRBACKENDTYPE_TESSERACT: &str = "Tesseract";
pub const OCRBACKENDTYPE_EASYOCR: &str = "EasyOCR";
pub const OCRBACKENDTYPE_PADDLEOCR: &str = "PaddleOCR";
pub const OCRBACKENDTYPE_CUSTOM: &str = "Custom";

// ProcessingStage enum values
pub const PROCESSINGSTAGE_EARLY: &str = "Early";
pub const PROCESSINGSTAGE_MIDDLE: &str = "Middle";
pub const PROCESSINGSTAGE_LATE: &str = "Late";

// ReductionLevel enum values
pub const REDUCTIONLEVEL_OFF: &str = "Off";
pub const REDUCTIONLEVEL_LIGHT: &str = "Light";
pub const REDUCTIONLEVEL_MODERATE: &str = "Moderate";
pub const REDUCTIONLEVEL_AGGRESSIVE: &str = "Aggressive";
pub const REDUCTIONLEVEL_MAXIMUM: &str = "Maximum";

// PdfAnnotationType enum values
pub const PDFANNOTATIONTYPE_TEXT: &str = "Text";
pub const PDFANNOTATIONTYPE_HIGHLIGHT: &str = "Highlight";
pub const PDFANNOTATIONTYPE_LINK: &str = "Link";
pub const PDFANNOTATIONTYPE_STAMP: &str = "Stamp";
pub const PDFANNOTATIONTYPE_UNDERLINE: &str = "Underline";
pub const PDFANNOTATIONTYPE_STRIKEOUT: &str = "StrikeOut";
pub const PDFANNOTATIONTYPE_OTHER: &str = "Other";

// BlockType enum values
pub const BLOCKTYPE_PARAGRAPH: &str = "Paragraph";
pub const BLOCKTYPE_HEADING: &str = "Heading";
pub const BLOCKTYPE_BLOCKQUOTE: &str = "Blockquote";
pub const BLOCKTYPE_CODEBLOCK: &str = "CodeBlock";
pub const BLOCKTYPE_LISTITEM: &str = "ListItem";
pub const BLOCKTYPE_ORDEREDLIST: &str = "OrderedList";
pub const BLOCKTYPE_BULLETLIST: &str = "BulletList";
pub const BLOCKTYPE_TASKLIST: &str = "TaskList";
pub const BLOCKTYPE_DEFINITIONLIST: &str = "DefinitionList";
pub const BLOCKTYPE_DEFINITIONTERM: &str = "DefinitionTerm";
pub const BLOCKTYPE_DEFINITIONDESCRIPTION: &str = "DefinitionDescription";
pub const BLOCKTYPE_DIV: &str = "Div";
pub const BLOCKTYPE_SECTION: &str = "Section";
pub const BLOCKTYPE_THEMATICBREAK: &str = "ThematicBreak";
pub const BLOCKTYPE_RAWBLOCK: &str = "RawBlock";
pub const BLOCKTYPE_MATHDISPLAY: &str = "MathDisplay";

// InlineType enum values
pub const INLINETYPE_TEXT: &str = "Text";
pub const INLINETYPE_STRONG: &str = "Strong";
pub const INLINETYPE_EMPHASIS: &str = "Emphasis";
pub const INLINETYPE_HIGHLIGHT: &str = "Highlight";
pub const INLINETYPE_SUBSCRIPT: &str = "Subscript";
pub const INLINETYPE_SUPERSCRIPT: &str = "Superscript";
pub const INLINETYPE_INSERT: &str = "Insert";
pub const INLINETYPE_DELETE: &str = "Delete";
pub const INLINETYPE_CODE: &str = "Code";
pub const INLINETYPE_LINK: &str = "Link";
pub const INLINETYPE_IMAGE: &str = "Image";
pub const INLINETYPE_SPAN: &str = "Span";
pub const INLINETYPE_MATH: &str = "Math";
pub const INLINETYPE_RAWINLINE: &str = "RawInline";
pub const INLINETYPE_FOOTNOTEREF: &str = "FootnoteRef";
pub const INLINETYPE_SYMBOL: &str = "Symbol";

// RelationshipKind enum values
pub const RELATIONSHIPKIND_FOOTNOTEREFERENCE: &str = "FootnoteReference";
pub const RELATIONSHIPKIND_CITATIONREFERENCE: &str = "CitationReference";
pub const RELATIONSHIPKIND_INTERNALLINK: &str = "InternalLink";
pub const RELATIONSHIPKIND_CAPTION: &str = "Caption";
pub const RELATIONSHIPKIND_LABEL: &str = "Label";
pub const RELATIONSHIPKIND_TOCENTRY: &str = "TocEntry";
pub const RELATIONSHIPKIND_CROSSREFERENCE: &str = "CrossReference";

// ContentLayer enum values
pub const CONTENTLAYER_BODY: &str = "Body";
pub const CONTENTLAYER_HEADER: &str = "Header";
pub const CONTENTLAYER_FOOTER: &str = "Footer";
pub const CONTENTLAYER_FOOTNOTE: &str = "Footnote";

#[php_class]
#[php(name = "Kreuzberg\\NodeContent")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct NodeContent {
    #[php(prop, name = "node_type")]
    #[serde(rename = "node_type")]
    pub node_type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub level: Option<u8>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ordered: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub grid: Option<TableGrid>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image_index: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub src: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub heading_level: Option<u8>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub heading_text: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub number: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub term: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub definition: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub key: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub content: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub entries: Option<Vec<Vec<String>>>,
}

#[php_impl]
impl NodeContent {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_node_type_tag(&self) -> String {
        self.node_type_tag.clone()
    }

    #[php(getter)]
    pub fn get_text(&self) -> Option<String> {
        self.text.clone()
    }

    #[php(getter)]
    pub fn get_level(&self) -> Option<u8> {
        self.level
    }

    #[php(getter)]
    pub fn get_ordered(&self) -> Option<bool> {
        self.ordered
    }

    #[php(getter)]
    pub fn get_grid(&self) -> Option<TableGrid> {
        self.grid.clone()
    }

    #[php(getter)]
    pub fn get_description(&self) -> Option<String> {
        self.description.clone()
    }

    #[php(getter)]
    pub fn get_image_index(&self) -> Option<u32> {
        self.image_index
    }

    #[php(getter)]
    pub fn get_src(&self) -> Option<String> {
        self.src.clone()
    }

    #[php(getter)]
    pub fn get_language(&self) -> Option<String> {
        self.language.clone()
    }

    #[php(getter)]
    pub fn get_label(&self) -> Option<String> {
        self.label.clone()
    }

    #[php(getter)]
    pub fn get_heading_level(&self) -> Option<u8> {
        self.heading_level
    }

    #[php(getter)]
    pub fn get_heading_text(&self) -> Option<String> {
        self.heading_text.clone()
    }

    #[php(getter)]
    pub fn get_number(&self) -> Option<u32> {
        self.number
    }

    #[php(getter)]
    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    #[php(getter)]
    pub fn get_term(&self) -> Option<String> {
        self.term.clone()
    }

    #[php(getter)]
    pub fn get_definition(&self) -> Option<String> {
        self.definition.clone()
    }

    #[php(getter)]
    pub fn get_key(&self) -> Option<String> {
        self.key.clone()
    }

    #[php(getter)]
    pub fn get_kind(&self) -> Option<String> {
        self.kind.clone()
    }

    #[php(getter)]
    pub fn get_format(&self) -> Option<String> {
        self.format.clone()
    }

    #[php(getter)]
    pub fn get_content(&self) -> Option<String> {
        self.content.clone()
    }

    #[php(getter)]
    pub fn get_entries(&self) -> Option<Vec<Vec<String>>> {
        self.entries.clone()
    }
}

#[php_class]
#[php(name = "Kreuzberg\\AnnotationKind")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AnnotationKind {
    #[php(prop, name = "annotation_type")]
    #[serde(rename = "annotation_type")]
    pub annotation_type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub url: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub value: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

#[php_impl]
impl AnnotationKind {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_annotation_type_tag(&self) -> String {
        self.annotation_type_tag.clone()
    }

    #[php(getter)]
    pub fn get_url(&self) -> Option<String> {
        self.url.clone()
    }

    #[php(getter)]
    pub fn get_title(&self) -> Option<String> {
        self.title.clone()
    }

    #[php(getter)]
    pub fn get_value(&self) -> Option<String> {
        self.value.clone()
    }

    #[php(getter)]
    pub fn get_name(&self) -> Option<String> {
        self.name.clone()
    }
}

// ExtractionMethod enum values
pub const EXTRACTIONMETHOD_NATIVE: &str = "Native";
pub const EXTRACTIONMETHOD_OCR: &str = "Ocr";
pub const EXTRACTIONMETHOD_MIXED: &str = "Mixed";

// ChunkType enum values
pub const CHUNKTYPE_HEADING: &str = "Heading";
pub const CHUNKTYPE_PARTYLIST: &str = "PartyList";
pub const CHUNKTYPE_DEFINITIONS: &str = "Definitions";
pub const CHUNKTYPE_OPERATIVECLAUSE: &str = "OperativeClause";
pub const CHUNKTYPE_SIGNATUREBLOCK: &str = "SignatureBlock";
pub const CHUNKTYPE_SCHEDULE: &str = "Schedule";
pub const CHUNKTYPE_TABLELIKE: &str = "TableLike";
pub const CHUNKTYPE_FORMULA: &str = "Formula";
pub const CHUNKTYPE_CODEBLOCK: &str = "CodeBlock";
pub const CHUNKTYPE_IMAGE: &str = "Image";
pub const CHUNKTYPE_ORGCHART: &str = "OrgChart";
pub const CHUNKTYPE_DIAGRAM: &str = "Diagram";
pub const CHUNKTYPE_UNKNOWN: &str = "Unknown";

// ImageKind enum values
pub const IMAGEKIND_PHOTOGRAPH: &str = "Photograph";
pub const IMAGEKIND_DIAGRAM: &str = "Diagram";
pub const IMAGEKIND_CHART: &str = "Chart";
pub const IMAGEKIND_DRAWING: &str = "Drawing";
pub const IMAGEKIND_TEXTBLOCK: &str = "TextBlock";
pub const IMAGEKIND_DECORATION: &str = "Decoration";
pub const IMAGEKIND_LOGO: &str = "Logo";
pub const IMAGEKIND_ICON: &str = "Icon";
pub const IMAGEKIND_TILEFRAGMENT: &str = "TileFragment";
pub const IMAGEKIND_MASK: &str = "Mask";
pub const IMAGEKIND_PAGERASTER: &str = "PageRaster";
pub const IMAGEKIND_UNKNOWN: &str = "Unknown";

// ResultFormat enum values
pub const RESULTFORMAT_UNIFIED: &str = "Unified";
pub const RESULTFORMAT_ELEMENTBASED: &str = "ElementBased";

// ElementType enum values
pub const ELEMENTTYPE_TITLE: &str = "Title";
pub const ELEMENTTYPE_NARRATIVETEXT: &str = "NarrativeText";
pub const ELEMENTTYPE_HEADING: &str = "Heading";
pub const ELEMENTTYPE_LISTITEM: &str = "ListItem";
pub const ELEMENTTYPE_TABLE: &str = "Table";
pub const ELEMENTTYPE_IMAGE: &str = "Image";
pub const ELEMENTTYPE_PAGEBREAK: &str = "PageBreak";
pub const ELEMENTTYPE_CODEBLOCK: &str = "CodeBlock";
pub const ELEMENTTYPE_BLOCKQUOTE: &str = "BlockQuote";
pub const ELEMENTTYPE_FOOTER: &str = "Footer";
pub const ELEMENTTYPE_HEADER: &str = "Header";

#[php_class]
#[php(name = "Kreuzberg\\FormatMetadata")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct FormatMetadata {
    #[php(prop, name = "format_type")]
    #[serde(rename = "format_type")]
    pub format_type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pdf: Option<PdfMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub docx: Option<DocxMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub excel: Option<ExcelMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub email: Option<EmailMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pptx: Option<PptxMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub archive: Option<ArchiveMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub image: Option<ImageMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub xml: Option<XmlMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<TextMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub html: Option<HtmlMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ocr: Option<OcrMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub csv: Option<CsvMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bibtex: Option<BibtexMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub citation: Option<CitationMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub fiction_book: Option<FictionBookMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dbf: Option<DbfMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub jats: Option<JatsMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub epub: Option<EpubMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pst: Option<PstMetadata>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub code: Option<String>,
}

#[php_impl]
impl FormatMetadata {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_format_type_tag(&self) -> String {
        self.format_type_tag.clone()
    }

    #[php(getter)]
    pub fn get_pdf(&self) -> Option<PdfMetadata> {
        self.pdf.clone()
    }

    #[php(getter)]
    pub fn get_docx(&self) -> Option<DocxMetadata> {
        self.docx.clone()
    }

    #[php(getter)]
    pub fn get_excel(&self) -> Option<ExcelMetadata> {
        self.excel.clone()
    }

    #[php(getter)]
    pub fn get_email(&self) -> Option<EmailMetadata> {
        self.email.clone()
    }

    #[php(getter)]
    pub fn get_pptx(&self) -> Option<PptxMetadata> {
        self.pptx.clone()
    }

    #[php(getter)]
    pub fn get_archive(&self) -> Option<ArchiveMetadata> {
        self.archive.clone()
    }

    #[php(getter)]
    pub fn get_image(&self) -> Option<ImageMetadata> {
        self.image.clone()
    }

    #[php(getter)]
    pub fn get_xml(&self) -> Option<XmlMetadata> {
        self.xml.clone()
    }

    #[php(getter)]
    pub fn get_text(&self) -> Option<TextMetadata> {
        self.text.clone()
    }

    #[php(getter)]
    pub fn get_html(&self) -> Option<HtmlMetadata> {
        self.html.clone()
    }

    #[php(getter)]
    pub fn get_ocr(&self) -> Option<OcrMetadata> {
        self.ocr.clone()
    }

    #[php(getter)]
    pub fn get_csv(&self) -> Option<CsvMetadata> {
        self.csv.clone()
    }

    #[php(getter)]
    pub fn get_bibtex(&self) -> Option<BibtexMetadata> {
        self.bibtex.clone()
    }

    #[php(getter)]
    pub fn get_citation(&self) -> Option<CitationMetadata> {
        self.citation.clone()
    }

    #[php(getter)]
    pub fn get_fiction_book(&self) -> Option<FictionBookMetadata> {
        self.fiction_book.clone()
    }

    #[php(getter)]
    pub fn get_dbf(&self) -> Option<DbfMetadata> {
        self.dbf.clone()
    }

    #[php(getter)]
    pub fn get_jats(&self) -> Option<JatsMetadata> {
        self.jats.clone()
    }

    #[php(getter)]
    pub fn get_epub(&self) -> Option<EpubMetadata> {
        self.epub.clone()
    }

    #[php(getter)]
    pub fn get_pst(&self) -> Option<PstMetadata> {
        self.pst.clone()
    }

    #[php(getter)]
    pub fn get_code(&self) -> Option<String> {
        self.code.clone()
    }
}

// TextDirection enum values
pub const TEXTDIRECTION_LEFTTORIGHT: &str = "LeftToRight";
pub const TEXTDIRECTION_RIGHTTOLEFT: &str = "RightToLeft";
pub const TEXTDIRECTION_AUTO: &str = "Auto";

// LinkType enum values
pub const LINKTYPE_ANCHOR: &str = "Anchor";
pub const LINKTYPE_INTERNAL: &str = "Internal";
pub const LINKTYPE_EXTERNAL: &str = "External";
pub const LINKTYPE_EMAIL: &str = "Email";
pub const LINKTYPE_PHONE: &str = "Phone";
pub const LINKTYPE_OTHER: &str = "Other";

// ImageType enum values
pub const IMAGETYPE_DATAURI: &str = "DataUri";
pub const IMAGETYPE_INLINESVG: &str = "InlineSvg";
pub const IMAGETYPE_EXTERNAL: &str = "External";
pub const IMAGETYPE_RELATIVE: &str = "Relative";

// StructuredDataType enum values
pub const STRUCTUREDDATATYPE_JSONLD: &str = "JsonLd";
pub const STRUCTUREDDATATYPE_MICRODATA: &str = "Microdata";
pub const STRUCTUREDDATATYPE_RDFA: &str = "RDFa";

#[php_class]
#[php(name = "Kreuzberg\\OcrBoundingGeometry")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct OcrBoundingGeometry {
    #[php(prop, name = "type")]
    #[serde(rename = "type")]
    pub type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub left: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub top: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub width: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub height: Option<u32>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub points: Option<String>,
}

#[php_impl]
impl OcrBoundingGeometry {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_type_tag(&self) -> String {
        self.type_tag.clone()
    }

    #[php(getter)]
    pub fn get_left(&self) -> Option<u32> {
        self.left
    }

    #[php(getter)]
    pub fn get_top(&self) -> Option<u32> {
        self.top
    }

    #[php(getter)]
    pub fn get_width(&self) -> Option<u32> {
        self.width
    }

    #[php(getter)]
    pub fn get_height(&self) -> Option<u32> {
        self.height
    }

    #[php(getter)]
    pub fn get_points(&self) -> Option<String> {
        self.points.clone()
    }
}

// OcrElementLevel enum values
pub const OCRELEMENTLEVEL_WORD: &str = "Word";
pub const OCRELEMENTLEVEL_LINE: &str = "Line";
pub const OCRELEMENTLEVEL_BLOCK: &str = "Block";
pub const OCRELEMENTLEVEL_PAGE: &str = "Page";

// PageUnitType enum values
pub const PAGEUNITTYPE_PAGE: &str = "Page";
pub const PAGEUNITTYPE_SLIDE: &str = "Slide";
pub const PAGEUNITTYPE_SHEET: &str = "Sheet";

#[php_class]
#[php(name = "Kreuzberg\\DiffLine")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct DiffLine {
    #[php(prop, name = "kind")]
    #[serde(rename = "kind")]
    pub kind_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub context: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub added: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub removed: Option<String>,
}

#[php_impl]
impl DiffLine {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_kind_tag(&self) -> String {
        self.kind_tag.clone()
    }

    #[php(getter)]
    pub fn get_context(&self) -> Option<String> {
        self.context.clone()
    }

    #[php(getter)]
    pub fn get_added(&self) -> Option<String> {
        self.added.clone()
    }

    #[php(getter)]
    pub fn get_removed(&self) -> Option<String> {
        self.removed.clone()
    }
}

// RevisionKind enum values
pub const REVISIONKIND_INSERTION: &str = "Insertion";
pub const REVISIONKIND_DELETION: &str = "Deletion";
pub const REVISIONKIND_FORMATCHANGE: &str = "FormatChange";
pub const REVISIONKIND_COMMENT: &str = "Comment";

#[php_class]
#[php(name = "Kreuzberg\\RevisionAnchor")]
#[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct RevisionAnchor {
    #[php(prop, name = "type")]
    #[serde(rename = "type")]
    pub type_tag: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub index: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub row: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub col: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub table_index: Option<i64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
}

#[php_impl]
impl RevisionAnchor {
    #[php(name = "from_json")]
    pub fn from_json(json: String) -> PhpResult<Self> {
        serde_json::from_str(&json).map_err(|e| PhpException::default(e.to_string()))
    }

    #[php(getter)]
    pub fn get_type_tag(&self) -> String {
        self.type_tag.clone()
    }

    #[php(getter)]
    pub fn get_index(&self) -> Option<i64> {
        self.index
    }

    #[php(getter)]
    pub fn get_row(&self) -> Option<i64> {
        self.row
    }

    #[php(getter)]
    pub fn get_col(&self) -> Option<i64> {
        self.col
    }

    #[php(getter)]
    pub fn get_table_index(&self) -> Option<i64> {
        self.table_index
    }

    #[php(getter)]
    pub fn get_name(&self) -> Option<String> {
        self.name.clone()
    }
}

// UriKind enum values
pub const URIKIND_HYPERLINK: &str = "Hyperlink";
pub const URIKIND_IMAGE: &str = "Image";
pub const URIKIND_ANCHOR: &str = "Anchor";
pub const URIKIND_CITATION: &str = "Citation";
pub const URIKIND_REFERENCE: &str = "Reference";
pub const URIKIND_EMAIL: &str = "Email";

// KeywordAlgorithm enum values
pub const KEYWORDALGORITHM_YAKE: &str = "Yake";
pub const KEYWORDALGORITHM_RAKE: &str = "Rake";

// PSMMode enum values
pub const PSMMODE_OSDONLY: &str = "OsdOnly";
pub const PSMMODE_AUTOOSD: &str = "AutoOsd";
pub const PSMMODE_AUTOONLY: &str = "AutoOnly";
pub const PSMMODE_AUTO: &str = "Auto";
pub const PSMMODE_SINGLECOLUMN: &str = "SingleColumn";
pub const PSMMODE_SINGLEBLOCKVERTICAL: &str = "SingleBlockVertical";
pub const PSMMODE_SINGLEBLOCK: &str = "SingleBlock";
pub const PSMMODE_SINGLELINE: &str = "SingleLine";
pub const PSMMODE_SINGLEWORD: &str = "SingleWord";
pub const PSMMODE_CIRCLEWORD: &str = "CircleWord";
pub const PSMMODE_SINGLECHAR: &str = "SingleChar";

// PaddleLanguage enum values
pub const PADDLELANGUAGE_ENGLISH: &str = "English";
pub const PADDLELANGUAGE_CHINESE: &str = "Chinese";
pub const PADDLELANGUAGE_JAPANESE: &str = "Japanese";
pub const PADDLELANGUAGE_KOREAN: &str = "Korean";
pub const PADDLELANGUAGE_GERMAN: &str = "German";
pub const PADDLELANGUAGE_FRENCH: &str = "French";
pub const PADDLELANGUAGE_LATIN: &str = "Latin";
pub const PADDLELANGUAGE_CYRILLIC: &str = "Cyrillic";
pub const PADDLELANGUAGE_TRADITIONALCHINESE: &str = "TraditionalChinese";
pub const PADDLELANGUAGE_THAI: &str = "Thai";
pub const PADDLELANGUAGE_GREEK: &str = "Greek";
pub const PADDLELANGUAGE_EASTSLAVIC: &str = "EastSlavic";
pub const PADDLELANGUAGE_ARABIC: &str = "Arabic";
pub const PADDLELANGUAGE_DEVANAGARI: &str = "Devanagari";
pub const PADDLELANGUAGE_TAMIL: &str = "Tamil";
pub const PADDLELANGUAGE_TELUGU: &str = "Telugu";

// LayoutClass enum values
pub const LAYOUTCLASS_CAPTION: &str = "Caption";
pub const LAYOUTCLASS_FOOTNOTE: &str = "Footnote";
pub const LAYOUTCLASS_FORMULA: &str = "Formula";
pub const LAYOUTCLASS_LISTITEM: &str = "ListItem";
pub const LAYOUTCLASS_PAGEFOOTER: &str = "PageFooter";
pub const LAYOUTCLASS_PAGEHEADER: &str = "PageHeader";
pub const LAYOUTCLASS_PICTURE: &str = "Picture";
pub const LAYOUTCLASS_SECTIONHEADER: &str = "SectionHeader";
pub const LAYOUTCLASS_TABLE: &str = "Table";
pub const LAYOUTCLASS_TEXT: &str = "Text";
pub const LAYOUTCLASS_TITLE: &str = "Title";
pub const LAYOUTCLASS_DOCUMENTINDEX: &str = "DocumentIndex";
pub const LAYOUTCLASS_CODE: &str = "Code";
pub const LAYOUTCLASS_CHECKBOXSELECTED: &str = "CheckboxSelected";
pub const LAYOUTCLASS_CHECKBOXUNSELECTED: &str = "CheckboxUnselected";
pub const LAYOUTCLASS_FORM: &str = "Form";
pub const LAYOUTCLASS_KEYVALUEREGION: &str = "KeyValueRegion";

#[php_class]
#[php(name = "Kreuzberg\\KreuzbergApi")]
pub struct KreuzbergApi;

#[php_impl]
impl KreuzbergApi {
    /**
     * Extract content from a byte array.
     *
     * This is the main entry point for in-memory extraction. It performs the following steps:
     * 1. Validate MIME type
     * 2. Handle legacy format conversion if needed
     * 3. Select appropriate extractor from registry
     * 4. Extract content
     * 5. Run post-processing pipeline
     * @param mixed $content The byte array to extract
     *
     * @param mixed $mime_type MIME type of the content
     *
     * @param mixed $config Extraction configuration
     *
     * @return An `ExtractionResult` containing the extracted content and metadata.
     *
     * @throws KreuzbergException Returns `KreuzbergError.Validation` if MIME type is invalid.
     * Returns `KreuzbergError.UnsupportedFormat` if MIME type is not supported.
     */
    #[php(name = "extractBytes")]
    pub fn extract_bytes(
        content: PhpBytes,
        mimeType: String,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<ExtractionResult> {
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        WORKER_RUNTIME.block_on(async {
            let result = kreuzberg::extract_bytes(&content.0[..], &mimeType, &config_core.unwrap_or_default())
                .await
                .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
            Ok(result.into())
        })
    }

    /**
     * Extract content from a file.
     *
     * This is the main entry point for file-based extraction. It performs the following steps:
     * 1. Check cache for existing result (if caching enabled)
     * 2. Detect or validate MIME type
     * 3. Select appropriate extractor from registry
     * 4. Extract content
     * 5. Run post-processing pipeline
     * 6. Store result in cache (if caching enabled)
     * @param mixed $path Path to the file to extract
     *
     * @param mixed $mime_type Optional MIME type override. If null, will be auto-detected
     *
     * @param mixed $config Extraction configuration
     *
     * @return An `ExtractionResult` containing the extracted content and metadata.
     *
     * @throws KreuzbergException Returns `KreuzbergError.Io` if the file doesn't exist (NotFound) or for other file I/O errors.
     * Returns `KreuzbergError.UnsupportedFormat` if MIME type is not supported.
     */
    #[php(name = "extractFile")]
    pub fn extract_file(
        path: String,
        mimeType: Option<String>,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<ExtractionResult> {
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        WORKER_RUNTIME.block_on(async {
            let result = kreuzberg::extract_file(
                std::path::PathBuf::from(path),
                mimeType.as_deref(),
                &config_core.unwrap_or_default(),
            )
            .await
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
            Ok(result.into())
        })
    }

    /**
     * Synchronous wrapper for `extract_file`.
     *
     * This is a convenience function that blocks the current thread until extraction completes.
     * For async code, use `extract_file` directly.
     *
     * Uses the global Tokio runtime for 100x+ performance improvement over creating
     * a new runtime per call. Always uses the global runtime to avoid nested runtime issues.
     *
     * This function is only available with the `tokio-runtime` feature. For WASM targets,
     * use a truly synchronous extraction approach instead.
     *
     * # Example
     */
    #[php(name = "extractFileSync")]
    pub fn extract_file_sync(
        path: String,
        mimeType: Option<String>,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<ExtractionResult> {
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        let result = kreuzberg::extract_file_sync(
            std::path::PathBuf::from(path),
            mimeType.as_deref(),
            &config_core.unwrap_or_default(),
        )
        .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result.into())
    }

    /**
     * Synchronous wrapper for `extract_bytes`.
     *
     * Uses the global Tokio runtime for 100x+ performance improvement over creating
     * a new runtime per call.
     *
     * With the `tokio-runtime` feature, this blocks the current thread using the global
     * Tokio runtime. Without it (WASM), this calls a truly synchronous implementation.
     *
     * # Example
     */
    #[php(name = "extractBytesSync")]
    pub fn extract_bytes_sync(
        content: PhpBytes,
        mimeType: String,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<ExtractionResult> {
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        let result = kreuzberg::extract_bytes_sync(&content.0[..], &mimeType, &config_core.unwrap_or_default())
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result.into())
    }

    /**
     * Synchronous wrapper for `batch_extract_files`.
     *
     * Uses the global Tokio runtime for optimal performance.
     * Only available with `tokio-runtime` (WASM has no filesystem).
     *
     * # Example
     */
    #[php(name = "batchExtractFilesSync")]
    pub fn batch_extract_files_sync(
        items: &ext_php_rs::types::ZendHashTable,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<Vec<ExtractionResult>> {
        let mut items_core_result: Vec<kreuzberg::BatchFileItem> = Vec::new();
        for (_, item) in items.iter() {
            if let Some(parsed) = <&BatchFileItem as ext_php_rs::convert::FromZval>::from_zval(item) {
                items_core_result.push(parsed.clone().into());
            } else {
                return Err(ext_php_rs::exception::PhpException::default(
                    "Failed to convert array element to BatchFileItem".to_string(),
                ));
            }
        }
        let items_core: Vec<kreuzberg::BatchFileItem> = items_core_result;
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        let result = kreuzberg::batch_extract_files_sync(items_core, &config_core.unwrap_or_default())
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result.into_iter().map(Into::into).collect())
    }

    /**
     * Synchronous wrapper for `batch_extract_bytes`.
     *
     * Uses the global Tokio runtime for optimal performance.
     * With the `tokio-runtime` feature, this blocks the current thread using the global
     * Tokio runtime. Without it (WASM), this calls a truly synchronous implementation
     * that iterates through items and calls `extract_bytes_sync()`.
     *
     * # Example
     */
    #[php(name = "batchExtractBytesSync")]
    pub fn batch_extract_bytes_sync(
        items: &ext_php_rs::types::ZendHashTable,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<Vec<ExtractionResult>> {
        let mut items_core_result: Vec<kreuzberg::BatchBytesItem> = Vec::new();
        for (_, item) in items.iter() {
            if let Some(parsed) = <&BatchBytesItem as ext_php_rs::convert::FromZval>::from_zval(item) {
                items_core_result.push(parsed.clone().into());
            } else {
                return Err(ext_php_rs::exception::PhpException::default(
                    "Failed to convert array element to BatchBytesItem".to_string(),
                ));
            }
        }
        let items_core: Vec<kreuzberg::BatchBytesItem> = items_core_result;
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        let result = kreuzberg::batch_extract_bytes_sync(items_core, &config_core.unwrap_or_default())
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result.into_iter().map(Into::into).collect())
    }

    /**
     * Extract content from multiple files concurrently.
     *
     * This function processes multiple files in parallel, automatically managing
     * concurrency to prevent resource exhaustion. The concurrency limit can be
     * configured via `ExtractionConfig.max_concurrent_extractions` or defaults
     * to `(num_cpus * 1.5).ceil()`.
     *
     * Each file can optionally specify a `FileExtractionConfig` that overrides specific
     * fields from the batch-level `config`. Pass `None` for a file to use the batch defaults.
     * Batch-level settings like `max_concurrent_extractions` and `use_cache` are always
     * taken from the batch-level `config`.
     * @param mixed $items Vector of `BatchFileItem` structs, each containing a path and optional per-file configuration overrides.
     *
     * @param mixed $config Batch-level extraction configuration (provides defaults and batch settings)
     *
     * @return A vector of `ExtractionResult` in the same order as the input items.
     *
     * @throws KreuzbergException Individual file errors are captured in the result metadata. System errors
     * (IO, RuntimeError equivalents) will bubble up and fail the entire batch.
     */
    #[php(name = "batchExtractFiles")]
    pub fn batch_extract_files(
        items: &ext_php_rs::types::ZendHashTable,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<Vec<ExtractionResult>> {
        let mut items_core_result: Vec<kreuzberg::BatchFileItem> = Vec::new();
        for (_, item) in items.iter() {
            if let Some(parsed) = <&BatchFileItem as ext_php_rs::convert::FromZval>::from_zval(item) {
                items_core_result.push(parsed.clone().into());
            } else {
                return Err(ext_php_rs::exception::PhpException::default(
                    "Failed to convert array element to BatchFileItem".to_string(),
                ));
            }
        }
        let items_core: Vec<kreuzberg::BatchFileItem> = items_core_result;
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        WORKER_RUNTIME.block_on(async {
            let result = kreuzberg::batch_extract_files(items_core, &config_core.unwrap_or_default())
                .await
                .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
            Ok(result.into_iter().map(Into::into).collect())
        })
    }

    /**
     * Extract content from multiple byte arrays concurrently.
     *
     * This function processes multiple byte arrays in parallel, automatically managing
     * concurrency to prevent resource exhaustion. The concurrency limit can be
     * configured via `ExtractionConfig.max_concurrent_extractions` or defaults
     * to `(num_cpus * 1.5).ceil()`.
     *
     * Each item can optionally specify a `FileExtractionConfig` that overrides specific
     * fields from the batch-level `config`. Pass `None` as the config to use
     * the batch-level defaults for that item.
     * @param mixed $items Vector of `BatchBytesItem` structs, each containing content bytes, MIME type, and optional per-item configuration overrides.
     *
     * @param mixed $config Batch-level extraction configuration
     *
     * @return A vector of `ExtractionResult` in the same order as the input items.
     */
    #[php(name = "batchExtractBytes")]
    pub fn batch_extract_bytes(
        items: &ext_php_rs::types::ZendHashTable,
        config: Option<&ExtractionConfig>,
    ) -> PhpResult<Vec<ExtractionResult>> {
        let mut items_core_result: Vec<kreuzberg::BatchBytesItem> = Vec::new();
        for (_, item) in items.iter() {
            if let Some(parsed) = <&BatchBytesItem as ext_php_rs::convert::FromZval>::from_zval(item) {
                items_core_result.push(parsed.clone().into());
            } else {
                return Err(ext_php_rs::exception::PhpException::default(
                    "Failed to convert array element to BatchBytesItem".to_string(),
                ));
            }
        }
        let items_core: Vec<kreuzberg::BatchBytesItem> = items_core_result;
        let config_core: Option<kreuzberg::ExtractionConfig> = config.map(|v| v.clone().into());
        WORKER_RUNTIME.block_on(async {
            let result = kreuzberg::batch_extract_bytes(items_core, &config_core.unwrap_or_default())
                .await
                .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
            Ok(result.into_iter().map(Into::into).collect())
        })
    }

    /**
     * Detect MIME type from raw file bytes.
     *
     * Uses magic byte signatures to detect file type from content.
     * Falls back to `infer` crate for comprehensive detection.
     *
     * For ZIP-based files, inspects contents to distinguish Office Open XML
     * formats (DOCX, XLSX, PPTX) from plain ZIP archives.
     * @param mixed $content Raw file bytes
     *
     * @return The detected MIME type string.
     *
     * @throws KreuzbergException Returns `KreuzbergError.UnsupportedFormat` if MIME type cannot be determined.
     */
    #[php(name = "detectMimeTypeFromBytes")]
    pub fn detect_mime_type_from_bytes(content: PhpBytes) -> PhpResult<String> {
        let result = kreuzberg::detect_mime_type_from_bytes(&content.0[..])
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * Get file extensions for a given MIME type.
     *
     * Returns all known file extensions that map to the specified MIME type.
     * @param mixed $mime_type The MIME type to look up
     *
     * @return A vector of file extensions (without leading dot) for the MIME type.
     */
    #[php(name = "getExtensionsForMime")]
    pub fn get_extensions_for_mime(mimeType: String) -> PhpResult<Vec<String>> {
        let result = kreuzberg::get_extensions_for_mime(&mimeType)
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * List the names of all registered embedding backends.
     *
     * Used by `kreuzberg-cli`, the api/mcp endpoints, and generated language
     * bindings.
     */
    #[php(name = "listEmbeddingBackends")]
    pub fn list_embedding_backends() -> PhpResult<Vec<String>> {
        let result = kreuzberg::list_embedding_backends()
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * List names of all registered document extractors.
     */
    #[php(name = "listDocumentExtractors")]
    pub fn list_document_extractors() -> PhpResult<Vec<String>> {
        let result = kreuzberg::list_document_extractors()
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * List all registered OCR backends.
     *
     * Returns the names of all OCR backends currently registered in the global registry.
     * @return A vector of OCR backend names.
     */
    #[php(name = "listOcrBackends")]
    pub fn list_ocr_backends() -> PhpResult<Vec<String>> {
        let result =
            kreuzberg::list_ocr_backends().map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * List all registered post-processor names.
     *
     * Returns a vector of all post-processor names currently registered in the
     * global registry.
     * @return - `Ok(Vec<String>)` - Vector of post-processor names
     * - `Err(...)` if the registry lock is poisoned
     */
    #[php(name = "listPostProcessors")]
    pub fn list_post_processors() -> PhpResult<Vec<String>> {
        let result = kreuzberg::list_post_processors()
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * List names of all registered renderers.
     * @throws KreuzbergException Returns an error if the registry lock is poisoned.
     */
    #[php(name = "listRenderers")]
    pub fn list_renderers() -> PhpResult<Vec<String>> {
        let result =
            kreuzberg::list_renderers().map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * List names of all registered validators.
     */
    #[php(name = "listValidators")]
    pub fn list_validators() -> PhpResult<Vec<String>> {
        let result =
            kreuzberg::list_validators().map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * Compare two extraction results and return a structured diff.
     *
     * The comparison is purely structural — no I/O, no side effects. All fields
     * of `ExtractionDiff` are populated according to the provided `DiffOptions`.
     * @param mixed $a — the "before" extraction result
     *
     * @param mixed $b — the "after" extraction result
     *
     * @param mixed $opts — controls which sections are compared and optional truncation
     */
    #[php(name = "compare")]
    pub fn compare(
        a: Option<&ExtractionResult>,
        b: Option<&ExtractionResult>,
        opts: Option<&DiffOptions>,
    ) -> ExtractionDiff {
        let a_core: Option<kreuzberg::ExtractionResult> = a.map(|v| v.clone().into());
        let b_core: Option<kreuzberg::ExtractionResult> = b.map(|v| v.clone().into());
        let opts_core: Option<kreuzberg::DiffOptions> = opts.map(|v| v.clone().into());
        kreuzberg::compare(
            &a_core.unwrap_or_default(),
            &b_core.unwrap_or_default(),
            &opts_core.unwrap_or_default(),
        )
        .into()
    }

    /**
     * Generate embeddings asynchronously for a list of text strings.
     *
     * This is the async counterpart to `embed_texts`. It offloads the blocking
     * ONNX inference work to a dedicated blocking thread pool via Tokio's
     * `spawn_blocking`, keeping the async executor free.
     *
     * Returns one embedding vector per input text in the same order.
     * @param mixed $texts Vec of strings to embed (owned, sent to blocking thread)
     *
     * @param mixed $config Embedding configuration specifying model, batch size, and normalization
     *
     * @throws KreuzbergException - `KreuzbergError.MissingDependency` if ONNX Runtime is not installed
     * - `KreuzbergError.Embedding` if the preset name is unknown, model download fails,
     *   or the blocking inference task panics
     */
    #[php(name = "embedTextsAsync")]
    pub fn embed_texts_async(texts: Vec<String>, config: Option<&EmbeddingConfig>) -> PhpResult<Vec<Vec<f32>>> {
        let config_core: Option<kreuzberg::EmbeddingConfig> = config.map(|v| v.clone().into());
        WORKER_RUNTIME.block_on(async {
            let result = kreuzberg::embed_texts_async(texts, &config_core.unwrap_or_default())
                .await
                .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
            Ok(result)
        })
    }

    /**
     * Render a single PDF page to PNG bytes.
     *
     * Returns raw PNG-encoded bytes for the specified page at the given DPI.
     * Uses pdf_oxide with tiny-skia for pure-Rust rendering.
     * @param mixed $pdf_bytes Raw PDF file bytes
     *
     * @param mixed $page_index Zero-based page index
     *
     * @param mixed $dpi Resolution in dots per inch (default: 150)
     *
     * @param mixed $password Optional password for encrypted PDFs
     *
     * @throws KreuzbergException Returns `KreuzbergError.Parsing` if the PDF cannot be opened, authenticated,
     * or rendered, or if `page_index` is out of range.
     */
    #[php(name = "renderPdfPageToPng")]
    pub fn render_pdf_page_to_png(
        pdfBytes: PhpBytes,
        pageIndex: i64,
        dpi: Option<i32>,
        password: Option<String>,
    ) -> PhpResult<String> {
        let result = kreuzberg::render_pdf_page_to_png(&pdfBytes.0[..], pageIndex as usize, dpi, password.as_deref())
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(String::from_utf8_lossy(&Vec::<u8>::from(result)).into_owned())
    }

    /**
     * Detect the MIME type of a file at the given path.
     *
     * Uses the file extension and optionally the file content to determine the MIME type.
     * Set `check_exists` to `true` to verify the file exists before detection.
     */
    #[php(name = "detectMimeType")]
    pub fn detect_mime_type(path: String, checkExists: bool) -> PhpResult<String> {
        let result = kreuzberg::detect_mime_type(path, checkExists)
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * Embed a list of texts using the configured embedding model.
     *
     * Returns a 2D vector where each inner vector is the embedding for the corresponding text.
     */
    #[php(name = "embedTexts")]
    pub fn embed_texts(texts: Vec<String>, config: Option<&EmbeddingConfig>) -> PhpResult<Vec<Vec<f32>>> {
        let config_core: Option<kreuzberg::EmbeddingConfig> = config.map(|v| v.clone().into());
        let result = kreuzberg::embed_texts(texts, &config_core.unwrap_or_default())
            .map_err(|e| ext_php_rs::exception::PhpException::default(e.to_string()))?;
        Ok(result)
    }

    /**
     * Get an embedding preset by name.
     *
     * Returns `None` if no preset with the given name exists. Returns an owned
     * clone so the value is safe to pass across FFI boundaries.
     */
    #[php(name = "getEmbeddingPreset")]
    pub fn get_embedding_preset(name: String) -> Option<EmbeddingPreset> {
        kreuzberg::get_embedding_preset(&name).map(Into::into)
    }

    /**
     * List the names of all available embedding presets.
     *
     * Returns owned `String`s so the values are safe to pass across FFI boundaries.
     */
    #[php(name = "listEmbeddingPresets")]
    pub fn list_embedding_presets() -> Vec<String> {
        kreuzberg::list_embedding_presets()
    }

    #[php(name = "registerOcrBackend")]
    pub fn register_ocr_backend(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
        crate::register_ocr_backend(backend)
    }

    #[php(name = "unregisterOcrBackend")]
    pub fn unregister_ocr_backend(name: String) -> ext_php_rs::prelude::PhpResult<()> {
        crate::unregister_ocr_backend(name)
    }

    #[php(name = "clearOcrBackends")]
    pub fn clear_ocr_backends() -> ext_php_rs::prelude::PhpResult<()> {
        crate::clear_ocr_backends()
    }

    #[php(name = "registerPostProcessor")]
    pub fn register_post_processor(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
        crate::register_post_processor(backend)
    }

    #[php(name = "unregisterPostProcessor")]
    pub fn unregister_post_processor(name: String) -> ext_php_rs::prelude::PhpResult<()> {
        crate::unregister_post_processor(name)
    }

    #[php(name = "clearPostProcessors")]
    pub fn clear_post_processors() -> ext_php_rs::prelude::PhpResult<()> {
        crate::clear_post_processors()
    }

    #[php(name = "registerValidator")]
    pub fn register_validator(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
        crate::register_validator(backend)
    }

    #[php(name = "unregisterValidator")]
    pub fn unregister_validator(name: String) -> ext_php_rs::prelude::PhpResult<()> {
        crate::unregister_validator(name)
    }

    #[php(name = "clearValidators")]
    pub fn clear_validators() -> ext_php_rs::prelude::PhpResult<()> {
        crate::clear_validators()
    }

    #[php(name = "registerEmbeddingBackend")]
    pub fn register_embedding_backend(
        backend: &mut ext_php_rs::types::ZendObject,
    ) -> ext_php_rs::prelude::PhpResult<()> {
        crate::register_embedding_backend(backend)
    }

    #[php(name = "unregisterEmbeddingBackend")]
    pub fn unregister_embedding_backend(name: String) -> ext_php_rs::prelude::PhpResult<()> {
        crate::unregister_embedding_backend(name)
    }

    #[php(name = "clearEmbeddingBackends")]
    pub fn clear_embedding_backends() -> ext_php_rs::prelude::PhpResult<()> {
        crate::clear_embedding_backends()
    }

    #[php(name = "registerDocumentExtractor")]
    pub fn register_document_extractor(
        backend: &mut ext_php_rs::types::ZendObject,
    ) -> ext_php_rs::prelude::PhpResult<()> {
        crate::register_document_extractor(backend)
    }

    #[php(name = "unregisterDocumentExtractor")]
    pub fn unregister_document_extractor(name: String) -> ext_php_rs::prelude::PhpResult<()> {
        crate::unregister_document_extractor(name)
    }

    #[php(name = "clearDocumentExtractors")]
    pub fn clear_document_extractors() -> ext_php_rs::prelude::PhpResult<()> {
        crate::clear_document_extractors()
    }

    #[php(name = "registerRenderer")]
    pub fn register_renderer(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
        crate::register_renderer(backend)
    }

    #[php(name = "unregisterRenderer")]
    pub fn unregister_renderer(name: String) -> ext_php_rs::prelude::PhpResult<()> {
        crate::unregister_renderer(name)
    }

    #[php(name = "clearRenderers")]
    pub fn clear_renderers() -> ext_php_rs::prelude::PhpResult<()> {
        crate::clear_renderers()
    }
}

/// Wrapper that bridges a foreign Php object to the `OcrBackend` trait.
pub struct PhpOcrBackendBridge {
    inner: *mut ext_php_rs::types::ZendObject,
    cached_name: String,
}

impl std::fmt::Debug for PhpOcrBackendBridge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PhpOcrBackendBridge")
    }
}

impl PhpOcrBackendBridge {
    /// Create a new bridge wrapping a PHP object.
    ///
    /// Validates that the PHP object provides all required methods.
    pub fn new(php_obj: &mut ext_php_rs::types::ZendObject) -> Self {
        // Validation of required methods is done in the registration function below.
        // Skipping debug_assert in constructor to avoid type issues with get_property.

        // Increment refcount to keep the object alive while registered.
        php_obj.inc_count();

        // Extract and cache name
        let cached_name = php_obj
            .try_call_method("name", vec![])
            .ok()
            .and_then(|v| v.string())
            .unwrap_or("unknown".into())
            .to_string();

        Self {
            inner: php_obj as *mut _,
            cached_name,
        }
    }
}

// SAFETY: PHP is single-threaded within a request context.
// The raw pointer to ZendObject is managed with inc_count/dec_count pairs.
unsafe impl Send for PhpOcrBackendBridge {}
unsafe impl Sync for PhpOcrBackendBridge {}

impl Drop for PhpOcrBackendBridge {
    fn drop(&mut self) {
        // SAFETY: Decrement refcount when the bridge is dropped.
        // The object pointer is guaranteed valid until this point.
        unsafe {
            if !self.inner.is_null() {
                (*self.inner).dec_count();
            }
        }
    }
}

impl kreuzberg::plugins::Plugin for PhpOcrBackendBridge {
    fn name(&self) -> &str {
        &self.cached_name
    }

    fn version(&self) -> String {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("version", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }

    fn initialize(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("initialize", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }

    fn shutdown(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("shutdown", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

#[async_trait::async_trait]
impl kreuzberg::OcrBackend for PhpOcrBackendBridge {
    async fn process_image(
        &self,
        image_bytes: &[u8],
        config: &kreuzberg::OcrConfig,
    ) -> std::result::Result<kreuzberg::ExtractionResult, kreuzberg::KreuzbergError> {
        let inner_obj = self.inner;
        let cached_name = self.cached_name.clone();

        // SAFETY: PHP objects are single-threaded within a request.
        // Use block_in_place if already in a tokio context; otherwise use block_on.
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            tokio::task::block_in_place(|| {
                handle.block_on(async {
                    match unsafe {
                        (*inner_obj).try_call_method(
                            "process_image",
                            [
                                ext_php_rs::types::Zval::try_from(format!("{:?}", image_bytes)).unwrap_or_default(),
                                ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                    .unwrap_or_default(),
                            ]
                            .iter()
                            .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                            .collect(),
                        )
                    } {
                        Ok(val) => {
                            let json_str = val.string().unwrap_or_default();
                            serde_json::from_str(&json_str)
                                .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                        }
                        Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                            "Plugin '{}' method 'process_image' failed: {}",
                            cached_name, e
                        ))),
                    }
                })
            })
        } else {
            WORKER_RUNTIME.block_on(async {
                match unsafe {
                    (*inner_obj).try_call_method(
                        "process_image",
                        [
                            ext_php_rs::types::Zval::try_from(format!("{:?}", image_bytes)).unwrap_or_default(),
                            ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                .unwrap_or_default(),
                        ]
                        .iter()
                        .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                        .collect(),
                    )
                } {
                    Ok(val) => {
                        let json_str = val.string().unwrap_or_default();
                        serde_json::from_str(&json_str)
                            .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                    }
                    Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                        "Plugin '{}' method 'process_image' failed: {}",
                        cached_name, e
                    ))),
                }
            })
        }
    }

    fn supports_language(&self, lang: &str) -> bool {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe {
            (*self.inner).try_call_method(
                "supports_language",
                [ext_php_rs::types::Zval::try_from(lang).unwrap_or_default()]
                    .iter()
                    .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                    .collect(),
            )
        };
        match result {
            Ok(val) => val.long().unwrap_or_default() != 0,
            Err(_) => Default::default(),
        }
    }

    fn backend_type(&self) -> kreuzberg::OcrBackendType {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("backend_type", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }
}

#[php_function]
pub fn register_ocr_backend(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
    let wrapper = PhpOcrBackendBridge::new(backend);
    let arc: std::sync::Arc<dyn kreuzberg::OcrBackend> = std::sync::Arc::new(wrapper);

    let registry = kreuzberg::plugins::registry::get_ocr_backend_registry();
    let mut registry = registry.write();
    registry
        .register(arc)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("Failed to register backend: {}", e)))?;
    Ok(())
}

#[php_function]
pub fn unregister_ocr_backend(name: String) -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::ocr_backend::unregister_ocr_backend(&name)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[php_function]
pub fn clear_ocr_backends() -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::ocr_backend::clear_ocr_backends()
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

/// Wrapper that bridges a foreign Php object to the `PostProcessor` trait.
pub struct PhpPostProcessorBridge {
    inner: *mut ext_php_rs::types::ZendObject,
    cached_name: String,
}

impl std::fmt::Debug for PhpPostProcessorBridge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PhpPostProcessorBridge")
    }
}

impl PhpPostProcessorBridge {
    /// Create a new bridge wrapping a PHP object.
    ///
    /// Validates that the PHP object provides all required methods.
    pub fn new(php_obj: &mut ext_php_rs::types::ZendObject) -> Self {
        // Validation of required methods is done in the registration function below.
        // Skipping debug_assert in constructor to avoid type issues with get_property.

        // Increment refcount to keep the object alive while registered.
        php_obj.inc_count();

        // Extract and cache name
        let cached_name = php_obj
            .try_call_method("name", vec![])
            .ok()
            .and_then(|v| v.string())
            .unwrap_or("unknown".into())
            .to_string();

        Self {
            inner: php_obj as *mut _,
            cached_name,
        }
    }
}

// SAFETY: PHP is single-threaded within a request context.
// The raw pointer to ZendObject is managed with inc_count/dec_count pairs.
unsafe impl Send for PhpPostProcessorBridge {}
unsafe impl Sync for PhpPostProcessorBridge {}

impl Drop for PhpPostProcessorBridge {
    fn drop(&mut self) {
        // SAFETY: Decrement refcount when the bridge is dropped.
        // The object pointer is guaranteed valid until this point.
        unsafe {
            if !self.inner.is_null() {
                (*self.inner).dec_count();
            }
        }
    }
}

impl kreuzberg::plugins::Plugin for PhpPostProcessorBridge {
    fn name(&self) -> &str {
        &self.cached_name
    }

    fn version(&self) -> String {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("version", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }

    fn initialize(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("initialize", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }

    fn shutdown(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("shutdown", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

#[async_trait::async_trait]
impl kreuzberg::PostProcessor for PhpPostProcessorBridge {
    async fn process(
        &self,
        result: &mut kreuzberg::ExtractionResult,
        config: &kreuzberg::ExtractionConfig,
    ) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        let inner_obj = self.inner;
        let cached_name = self.cached_name.clone();

        // SAFETY: PHP objects are single-threaded within a request.
        // Use block_in_place if already in a tokio context; otherwise use block_on.
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            tokio::task::block_in_place(|| {
                handle.block_on(async {
                    match unsafe {
                        (*inner_obj).try_call_method(
                            "process",
                            [
                                ext_php_rs::types::Zval::try_from(serde_json::to_string(&result).unwrap_or_default())
                                    .unwrap_or_default(),
                                ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                    .unwrap_or_default(),
                            ]
                            .iter()
                            .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                            .collect(),
                        )
                    } {
                        Ok(val) => {
                            let json_str = val.string().unwrap_or_default();
                            serde_json::from_str(&json_str)
                                .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                        }
                        Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                            "Plugin '{}' method 'process' failed: {}",
                            cached_name, e
                        ))),
                    }
                })
            })
        } else {
            WORKER_RUNTIME.block_on(async {
                match unsafe {
                    (*inner_obj).try_call_method(
                        "process",
                        [
                            ext_php_rs::types::Zval::try_from(serde_json::to_string(&result).unwrap_or_default())
                                .unwrap_or_default(),
                            ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                .unwrap_or_default(),
                        ]
                        .iter()
                        .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                        .collect(),
                    )
                } {
                    Ok(val) => {
                        let json_str = val.string().unwrap_or_default();
                        serde_json::from_str(&json_str)
                            .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                    }
                    Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                        "Plugin '{}' method 'process' failed: {}",
                        cached_name, e
                    ))),
                }
            })
        }
    }

    fn processing_stage(&self) -> kreuzberg::ProcessingStage {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("processing_stage", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }
}

#[php_function]
pub fn register_post_processor(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
    let wrapper = PhpPostProcessorBridge::new(backend);
    let arc: std::sync::Arc<dyn kreuzberg::PostProcessor> = std::sync::Arc::new(wrapper);

    let registry = kreuzberg::plugins::registry::get_post_processor_registry();
    let mut registry = registry.write();
    registry
        .register(arc)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("Failed to register backend: {}", e)))?;
    Ok(())
}

#[php_function]
pub fn unregister_post_processor(name: String) -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::post_processor::unregister_post_processor(&name)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[php_function]
pub fn clear_post_processors() -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::post_processor::clear_post_processors()
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

/// Wrapper that bridges a foreign Php object to the `Validator` trait.
pub struct PhpValidatorBridge {
    inner: *mut ext_php_rs::types::ZendObject,
    cached_name: String,
}

impl std::fmt::Debug for PhpValidatorBridge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PhpValidatorBridge")
    }
}

impl PhpValidatorBridge {
    /// Create a new bridge wrapping a PHP object.
    ///
    /// Validates that the PHP object provides all required methods.
    pub fn new(php_obj: &mut ext_php_rs::types::ZendObject) -> Self {
        // Validation of required methods is done in the registration function below.
        // Skipping debug_assert in constructor to avoid type issues with get_property.

        // Increment refcount to keep the object alive while registered.
        php_obj.inc_count();

        // Extract and cache name
        let cached_name = php_obj
            .try_call_method("name", vec![])
            .ok()
            .and_then(|v| v.string())
            .unwrap_or("unknown".into())
            .to_string();

        Self {
            inner: php_obj as *mut _,
            cached_name,
        }
    }
}

// SAFETY: PHP is single-threaded within a request context.
// The raw pointer to ZendObject is managed with inc_count/dec_count pairs.
unsafe impl Send for PhpValidatorBridge {}
unsafe impl Sync for PhpValidatorBridge {}

impl Drop for PhpValidatorBridge {
    fn drop(&mut self) {
        // SAFETY: Decrement refcount when the bridge is dropped.
        // The object pointer is guaranteed valid until this point.
        unsafe {
            if !self.inner.is_null() {
                (*self.inner).dec_count();
            }
        }
    }
}

impl kreuzberg::plugins::Plugin for PhpValidatorBridge {
    fn name(&self) -> &str {
        &self.cached_name
    }

    fn version(&self) -> String {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("version", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }

    fn initialize(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("initialize", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }

    fn shutdown(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("shutdown", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

#[async_trait::async_trait]
impl kreuzberg::Validator for PhpValidatorBridge {
    async fn validate(
        &self,
        result: &kreuzberg::ExtractionResult,
        config: &kreuzberg::ExtractionConfig,
    ) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        let inner_obj = self.inner;
        let cached_name = self.cached_name.clone();

        // SAFETY: PHP objects are single-threaded within a request.
        // Use block_in_place if already in a tokio context; otherwise use block_on.
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            tokio::task::block_in_place(|| {
                handle.block_on(async {
                    match unsafe {
                        (*inner_obj).try_call_method(
                            "validate",
                            [
                                ext_php_rs::types::Zval::try_from(serde_json::to_string(&result).unwrap_or_default())
                                    .unwrap_or_default(),
                                ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                    .unwrap_or_default(),
                            ]
                            .iter()
                            .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                            .collect(),
                        )
                    } {
                        Ok(val) => {
                            let json_str = val.string().unwrap_or_default();
                            serde_json::from_str(&json_str)
                                .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                        }
                        Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                            "Plugin '{}' method 'validate' failed: {}",
                            cached_name, e
                        ))),
                    }
                })
            })
        } else {
            WORKER_RUNTIME.block_on(async {
                match unsafe {
                    (*inner_obj).try_call_method(
                        "validate",
                        [
                            ext_php_rs::types::Zval::try_from(serde_json::to_string(&result).unwrap_or_default())
                                .unwrap_or_default(),
                            ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                .unwrap_or_default(),
                        ]
                        .iter()
                        .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                        .collect(),
                    )
                } {
                    Ok(val) => {
                        let json_str = val.string().unwrap_or_default();
                        serde_json::from_str(&json_str)
                            .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                    }
                    Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                        "Plugin '{}' method 'validate' failed: {}",
                        cached_name, e
                    ))),
                }
            })
        }
    }
}

#[php_function]
pub fn register_validator(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
    let wrapper = PhpValidatorBridge::new(backend);
    let arc: std::sync::Arc<dyn kreuzberg::Validator> = std::sync::Arc::new(wrapper);

    let registry = kreuzberg::plugins::registry::get_validator_registry();
    let mut registry = registry.write();
    registry
        .register(arc)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("Failed to register backend: {}", e)))?;
    Ok(())
}

#[php_function]
pub fn unregister_validator(name: String) -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::validator::unregister_validator(&name)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[php_function]
pub fn clear_validators() -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::validator::clear_validators()
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

/// Wrapper that bridges a foreign Php object to the `EmbeddingBackend` trait.
pub struct PhpEmbeddingBackendBridge {
    inner: *mut ext_php_rs::types::ZendObject,
    cached_name: String,
}

impl std::fmt::Debug for PhpEmbeddingBackendBridge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PhpEmbeddingBackendBridge")
    }
}

impl PhpEmbeddingBackendBridge {
    /// Create a new bridge wrapping a PHP object.
    ///
    /// Validates that the PHP object provides all required methods.
    pub fn new(php_obj: &mut ext_php_rs::types::ZendObject) -> Self {
        // Validation of required methods is done in the registration function below.
        // Skipping debug_assert in constructor to avoid type issues with get_property.

        // Increment refcount to keep the object alive while registered.
        php_obj.inc_count();

        // Extract and cache name
        let cached_name = php_obj
            .try_call_method("name", vec![])
            .ok()
            .and_then(|v| v.string())
            .unwrap_or("unknown".into())
            .to_string();

        Self {
            inner: php_obj as *mut _,
            cached_name,
        }
    }
}

// SAFETY: PHP is single-threaded within a request context.
// The raw pointer to ZendObject is managed with inc_count/dec_count pairs.
unsafe impl Send for PhpEmbeddingBackendBridge {}
unsafe impl Sync for PhpEmbeddingBackendBridge {}

impl Drop for PhpEmbeddingBackendBridge {
    fn drop(&mut self) {
        // SAFETY: Decrement refcount when the bridge is dropped.
        // The object pointer is guaranteed valid until this point.
        unsafe {
            if !self.inner.is_null() {
                (*self.inner).dec_count();
            }
        }
    }
}

impl kreuzberg::plugins::Plugin for PhpEmbeddingBackendBridge {
    fn name(&self) -> &str {
        &self.cached_name
    }

    fn version(&self) -> String {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("version", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }

    fn initialize(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("initialize", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }

    fn shutdown(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("shutdown", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

#[async_trait::async_trait]
impl kreuzberg::EmbeddingBackend for PhpEmbeddingBackendBridge {
    fn dimensions(&self) -> usize {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("dimensions", vec![]) };
        match result {
            Ok(val) => val.long().unwrap_or_default() as usize,
            Err(_) => Default::default(),
        }
    }

    async fn embed(&self, texts: Vec<String>) -> std::result::Result<Vec<Vec<f32>>, kreuzberg::KreuzbergError> {
        let inner_obj = self.inner;
        let cached_name = self.cached_name.clone();

        // SAFETY: PHP objects are single-threaded within a request.
        // Use block_in_place if already in a tokio context; otherwise use block_on.
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            tokio::task::block_in_place(|| {
                handle.block_on(async {
                    match unsafe {
                        (*inner_obj).try_call_method(
                            "embed",
                            [ext_php_rs::types::Zval::try_from(format!("{:?}", texts)).unwrap_or_default()]
                                .iter()
                                .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                                .collect(),
                        )
                    } {
                        Ok(val) => {
                            let json_str = val.string().unwrap_or_default();
                            serde_json::from_str(&json_str)
                                .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                        }
                        Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                            "Plugin '{}' method 'embed' failed: {}",
                            cached_name, e
                        ))),
                    }
                })
            })
        } else {
            WORKER_RUNTIME.block_on(async {
                match unsafe {
                    (*inner_obj).try_call_method(
                        "embed",
                        [ext_php_rs::types::Zval::try_from(format!("{:?}", texts)).unwrap_or_default()]
                            .iter()
                            .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                            .collect(),
                    )
                } {
                    Ok(val) => {
                        let json_str = val.string().unwrap_or_default();
                        serde_json::from_str(&json_str)
                            .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                    }
                    Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                        "Plugin '{}' method 'embed' failed: {}",
                        cached_name, e
                    ))),
                }
            })
        }
    }
}

#[php_function]
pub fn register_embedding_backend(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
    let wrapper = PhpEmbeddingBackendBridge::new(backend);
    let arc: std::sync::Arc<dyn kreuzberg::EmbeddingBackend> = std::sync::Arc::new(wrapper);

    let registry = kreuzberg::plugins::registry::get_embedding_backend_registry();
    let mut registry = registry.write();
    registry
        .register(arc)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("Failed to register backend: {}", e)))?;
    Ok(())
}

#[php_function]
pub fn unregister_embedding_backend(name: String) -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::embedding_backend::unregister_embedding_backend(&name)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[php_function]
pub fn clear_embedding_backends() -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::embedding_backend::clear_embedding_backends()
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

/// Wrapper that bridges a foreign Php object to the `DocumentExtractor` trait.
pub struct PhpDocumentExtractorBridge {
    inner: *mut ext_php_rs::types::ZendObject,
    cached_name: String,
}

impl std::fmt::Debug for PhpDocumentExtractorBridge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PhpDocumentExtractorBridge")
    }
}

impl PhpDocumentExtractorBridge {
    /// Create a new bridge wrapping a PHP object.
    ///
    /// Validates that the PHP object provides all required methods.
    pub fn new(php_obj: &mut ext_php_rs::types::ZendObject) -> Self {
        // Validation of required methods is done in the registration function below.
        // Skipping debug_assert in constructor to avoid type issues with get_property.

        // Increment refcount to keep the object alive while registered.
        php_obj.inc_count();

        // Extract and cache name
        let cached_name = php_obj
            .try_call_method("name", vec![])
            .ok()
            .and_then(|v| v.string())
            .unwrap_or("unknown".into())
            .to_string();

        Self {
            inner: php_obj as *mut _,
            cached_name,
        }
    }
}

// SAFETY: PHP is single-threaded within a request context.
// The raw pointer to ZendObject is managed with inc_count/dec_count pairs.
unsafe impl Send for PhpDocumentExtractorBridge {}
unsafe impl Sync for PhpDocumentExtractorBridge {}

impl Drop for PhpDocumentExtractorBridge {
    fn drop(&mut self) {
        // SAFETY: Decrement refcount when the bridge is dropped.
        // The object pointer is guaranteed valid until this point.
        unsafe {
            if !self.inner.is_null() {
                (*self.inner).dec_count();
            }
        }
    }
}

impl kreuzberg::plugins::Plugin for PhpDocumentExtractorBridge {
    fn name(&self) -> &str {
        &self.cached_name
    }

    fn version(&self) -> String {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("version", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }

    fn initialize(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("initialize", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }

    fn shutdown(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("shutdown", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

#[async_trait::async_trait]
impl kreuzberg::DocumentExtractor for PhpDocumentExtractorBridge {
    async fn extract_bytes(
        &self,
        content: &[u8],
        mime_type: &str,
        config: &kreuzberg::ExtractionConfig,
    ) -> std::result::Result<kreuzberg::InternalDocument, kreuzberg::KreuzbergError> {
        let inner_obj = self.inner;
        let cached_name = self.cached_name.clone();
        let mime_type = mime_type.to_string();

        // SAFETY: PHP objects are single-threaded within a request.
        // Use block_in_place if already in a tokio context; otherwise use block_on.
        if let Ok(handle) = tokio::runtime::Handle::try_current() {
            tokio::task::block_in_place(|| {
                handle.block_on(async {
                    match unsafe {
                        (*inner_obj).try_call_method(
                            "extract_bytes",
                            [
                                ext_php_rs::types::Zval::try_from(format!("{:?}", content)).unwrap_or_default(),
                                ext_php_rs::types::Zval::try_from(mime_type).unwrap_or_default(),
                                ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                    .unwrap_or_default(),
                            ]
                            .iter()
                            .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                            .collect(),
                        )
                    } {
                        Ok(val) => {
                            let json_str = val.string().unwrap_or_default();
                            serde_json::from_str(&json_str)
                                .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                        }
                        Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                            "Plugin '{}' method 'extract_bytes' failed: {}",
                            cached_name, e
                        ))),
                    }
                })
            })
        } else {
            WORKER_RUNTIME.block_on(async {
                match unsafe {
                    (*inner_obj).try_call_method(
                        "extract_bytes",
                        [
                            ext_php_rs::types::Zval::try_from(format!("{:?}", content)).unwrap_or_default(),
                            ext_php_rs::types::Zval::try_from(mime_type).unwrap_or_default(),
                            ext_php_rs::types::Zval::try_from(serde_json::to_string(&config).unwrap_or_default())
                                .unwrap_or_default(),
                        ]
                        .iter()
                        .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                        .collect(),
                    )
                } {
                    Ok(val) => {
                        let json_str = val.string().unwrap_or_default();
                        serde_json::from_str(&json_str)
                            .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
                    }
                    Err(e) => Err(kreuzberg::KreuzbergError::Other(format!(
                        "Plugin '{}' method 'extract_bytes' failed: {}",
                        cached_name, e
                    ))),
                }
            })
        }
    }

    fn supported_mime_types(&self) -> &[&str] {
        let __types: Vec<String> = {
            // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
            let result = unsafe { (*self.inner).try_call_method("supported_mime_types", vec![]) };
            match result {
                Ok(val) => {
                    let json_str = val.string().unwrap_or_default();
                    serde_json::from_str(&json_str).unwrap_or_default()
                }
                Err(_) => Default::default(),
            }
        };
        let __strs: Vec<&'static str> = __types
            .into_iter()
            .map(|s| -> &'static str { Box::leak(s.into_boxed_str()) })
            .collect();
        Box::leak(__strs.into_boxed_slice())
    }
}

#[php_function]
pub fn register_document_extractor(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
    let wrapper = PhpDocumentExtractorBridge::new(backend);
    let arc: std::sync::Arc<dyn kreuzberg::DocumentExtractor> = std::sync::Arc::new(wrapper);

    let registry = kreuzberg::plugins::registry::get_document_extractor_registry();
    let mut registry = registry.write();
    registry
        .register(arc)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("Failed to register backend: {}", e)))?;
    Ok(())
}

#[php_function]
pub fn unregister_document_extractor(name: String) -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::document_extractor::unregister_document_extractor(&name)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[php_function]
pub fn clear_document_extractors() -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::document_extractor::clear_document_extractors()
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

/// Wrapper that bridges a foreign Php object to the `Renderer` trait.
pub struct PhpRendererBridge {
    inner: *mut ext_php_rs::types::ZendObject,
    cached_name: String,
}

impl std::fmt::Debug for PhpRendererBridge {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "PhpRendererBridge")
    }
}

impl PhpRendererBridge {
    /// Create a new bridge wrapping a PHP object.
    ///
    /// Validates that the PHP object provides all required methods.
    pub fn new(php_obj: &mut ext_php_rs::types::ZendObject) -> Self {
        // Validation of required methods is done in the registration function below.
        // Skipping debug_assert in constructor to avoid type issues with get_property.

        // Increment refcount to keep the object alive while registered.
        php_obj.inc_count();

        // Extract and cache name
        let cached_name = php_obj
            .try_call_method("name", vec![])
            .ok()
            .and_then(|v| v.string())
            .unwrap_or("unknown".into())
            .to_string();

        Self {
            inner: php_obj as *mut _,
            cached_name,
        }
    }
}

// SAFETY: PHP is single-threaded within a request context.
// The raw pointer to ZendObject is managed with inc_count/dec_count pairs.
unsafe impl Send for PhpRendererBridge {}
unsafe impl Sync for PhpRendererBridge {}

impl Drop for PhpRendererBridge {
    fn drop(&mut self) {
        // SAFETY: Decrement refcount when the bridge is dropped.
        // The object pointer is guaranteed valid until this point.
        unsafe {
            if !self.inner.is_null() {
                (*self.inner).dec_count();
            }
        }
    }
}

impl kreuzberg::plugins::Plugin for PhpRendererBridge {
    fn name(&self) -> &str {
        &self.cached_name
    }

    fn version(&self) -> String {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("version", vec![]) };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str).unwrap_or_default()
            }
            Err(_) => Default::default(),
        }
    }

    fn initialize(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("initialize", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }

    fn shutdown(&self) -> std::result::Result<(), kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe { (*self.inner).try_call_method("shutdown", vec![]) };
        match result {
            Ok(_) => Ok(()),
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

impl kreuzberg::Renderer for PhpRendererBridge {
    fn render(&self, doc: &kreuzberg::InternalDocument) -> std::result::Result<String, kreuzberg::KreuzbergError> {
        // SAFETY: PHP objects are single-threaded; method calls are safe within a request.
        let result = unsafe {
            (*self.inner).try_call_method(
                "render",
                [
                    ext_php_rs::types::Zval::try_from(serde_json::to_string(&doc).unwrap_or_default())
                        .unwrap_or_default(),
                ]
                .iter()
                .map(|z| z as &dyn ext_php_rs::convert::IntoZvalDyn)
                .collect(),
            )
        };
        match result {
            Ok(val) => {
                let json_str = val.string().unwrap_or_default();
                serde_json::from_str(&json_str)
                    .map_err(|e| kreuzberg::KreuzbergError::Other(format!("Deserialize error: {}", e)))
            }
            Err(e) => Err(kreuzberg::KreuzbergError::Other(e.to_string())),
        }
    }
}

#[php_function]
pub fn register_renderer(backend: &mut ext_php_rs::types::ZendObject) -> ext_php_rs::prelude::PhpResult<()> {
    let wrapper = PhpRendererBridge::new(backend);
    let arc: std::sync::Arc<dyn kreuzberg::Renderer> = std::sync::Arc::new(wrapper);

    let registry = kreuzberg::plugins::registry::get_renderer_registry();
    let mut registry = registry.write();
    registry
        .register(arc)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("Failed to register backend: {}", e)))?;
    Ok(())
}

#[php_function]
pub fn unregister_renderer(name: String) -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::renderer::unregister_renderer(&name)
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[php_function]
pub fn clear_renderers() -> ext_php_rs::prelude::PhpResult<()> {
    kreuzberg::plugins::renderer::clear_renderers()
        .map_err(|e| ext_php_rs::exception::PhpException::default(format!("{}", e)))
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::CacheStats> for CacheStats {
    fn from(val: kreuzberg::CacheStats) -> Self {
        Self {
            total_files: val.total_files as i64,
            total_size_mb: val.total_size_mb,
            available_space_mb: val.available_space_mb,
            oldest_file_age_days: val.oldest_file_age_days,
            newest_file_age_days: val.newest_file_age_days,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<AccelerationConfig> for kreuzberg::AccelerationConfig {
    fn from(val: AccelerationConfig) -> Self {
        Self {
            provider: match val.provider.as_str() {
                "auto" => kreuzberg::ExecutionProviderType::Auto,
                "cpu" => kreuzberg::ExecutionProviderType::Cpu,
                "coreml" => kreuzberg::ExecutionProviderType::CoreMl,
                "cuda" => kreuzberg::ExecutionProviderType::Cuda,
                "tensorrt" => kreuzberg::ExecutionProviderType::TensorRt,
                _ => Default::default(),
            },
            device_id: val.device_id,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::AccelerationConfig> for AccelerationConfig {
    fn from(val: kreuzberg::AccelerationConfig) -> Self {
        Self {
            provider: serde_json::to_value(val.provider)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            device_id: val.device_id,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ContentFilterConfig> for kreuzberg::ContentFilterConfig {
    fn from(val: ContentFilterConfig) -> Self {
        Self {
            include_headers: val.include_headers,
            include_footers: val.include_footers,
            strip_repeating_text: val.strip_repeating_text,
            include_watermarks: val.include_watermarks,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ContentFilterConfig> for ContentFilterConfig {
    fn from(val: kreuzberg::ContentFilterConfig) -> Self {
        Self {
            include_headers: val.include_headers,
            include_footers: val.include_footers,
            strip_repeating_text: val.strip_repeating_text,
            include_watermarks: val.include_watermarks,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EmailConfig> for kreuzberg::EmailConfig {
    fn from(val: EmailConfig) -> Self {
        Self {
            msg_fallback_codepage: val.msg_fallback_codepage,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmailConfig> for EmailConfig {
    fn from(val: kreuzberg::EmailConfig) -> Self {
        Self {
            msg_fallback_codepage: val.msg_fallback_codepage,
        }
    }
}

#[allow(clippy::needless_update)]
#[allow(clippy::useless_conversion)]
impl From<ExtractionConfig> for kreuzberg::ExtractionConfig {
    fn from(val: ExtractionConfig) -> Self {
        Self {
            use_cache: val.use_cache,
            enable_quality_processing: val.enable_quality_processing,
            ocr: val.ocr.map(Into::into),
            force_ocr: val.force_ocr,
            force_ocr_pages: val.force_ocr_pages.map(|v| v.into_iter().collect()),
            disable_ocr: val.disable_ocr,
            chunking: val.chunking.map(Into::into),
            content_filter: val.content_filter.map(Into::into),
            images: val.images.map(Into::into),
            token_reduction: val.token_reduction.map(Into::into),
            language_detection: val.language_detection.map(Into::into),
            pages: val.pages.map(Into::into),
            postprocessor: val.postprocessor.map(Into::into),
            extraction_timeout_secs: val.extraction_timeout_secs.map(|v| v as u64),
            max_concurrent_extractions: val.max_concurrent_extractions.map(|v| v as usize),
            result_format: match val.result_format.as_str() {
                "unified" => kreuzberg::ResultFormat::Unified,
                "element_based" => kreuzberg::ResultFormat::ElementBased,
                _ => Default::default(),
            },
            security_limits: val.security_limits.map(Into::into),
            max_embedded_file_bytes: val.max_embedded_file_bytes.map(|v| v as u64),
            output_format: match val.output_format.as_str() {
                "plain" => kreuzberg::OutputFormat::Plain,
                "markdown" => kreuzberg::OutputFormat::Markdown,
                "djot" => kreuzberg::OutputFormat::Djot,
                "html" => kreuzberg::OutputFormat::Html,
                "json" => kreuzberg::OutputFormat::Json,
                "structured" => kreuzberg::OutputFormat::Structured,
                "custom" => kreuzberg::OutputFormat::Custom(Default::default()),
                _ => Default::default(),
            },
            use_layout_for_markdown: val.use_layout_for_markdown,
            include_document_structure: val.include_document_structure,
            acceleration: val.acceleration.map(Into::into),
            cache_namespace: val.cache_namespace,
            cache_ttl_secs: val.cache_ttl_secs.map(|v| v as u64),
            email: val.email.map(Into::into),
            concurrency: Default::default(),
            max_archive_depth: val.max_archive_depth as usize,
            structured_extraction: val.structured_extraction.map(Into::into),
            cancel_token: Default::default(),
            ..Default::default()
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExtractionConfig> for ExtractionConfig {
    fn from(val: kreuzberg::ExtractionConfig) -> Self {
        Self {
            use_cache: val.use_cache,
            enable_quality_processing: val.enable_quality_processing,
            ocr: val.ocr.map(Into::into),
            force_ocr: val.force_ocr,
            force_ocr_pages: val.force_ocr_pages.map(|v| v.into_iter().collect()),
            disable_ocr: val.disable_ocr,
            chunking: val.chunking.map(Into::into),
            content_filter: val.content_filter.map(Into::into),
            images: val.images.map(Into::into),
            pdf_options: val.pdf_options.map(Into::into),
            token_reduction: val.token_reduction.map(Into::into),
            language_detection: val.language_detection.map(Into::into),
            pages: val.pages.map(Into::into),
            keywords: val.keywords.map(Into::into),
            postprocessor: val.postprocessor.map(Into::into),
            html_options: val.html_options.as_ref().map(|v| format!("{v:?}")),
            html_output: val.html_output.map(Into::into),
            extraction_timeout_secs: val.extraction_timeout_secs.map(|v| v as i64),
            max_concurrent_extractions: val.max_concurrent_extractions.map(|v| v as i64),
            result_format: serde_json::to_value(val.result_format)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            security_limits: val.security_limits.map(Into::into),
            max_embedded_file_bytes: val.max_embedded_file_bytes.map(|v| v as i64),
            output_format: serde_json::to_value(val.output_format)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            layout: val.layout.map(Into::into),
            use_layout_for_markdown: val.use_layout_for_markdown,
            include_document_structure: val.include_document_structure,
            acceleration: val.acceleration.map(Into::into),
            cache_namespace: val.cache_namespace,
            cache_ttl_secs: val.cache_ttl_secs.map(|v| v as i64),
            email: val.email.map(Into::into),
            concurrency: val.concurrency.as_ref().map(|v| format!("{v:?}")),
            max_archive_depth: val.max_archive_depth as i64,
            tree_sitter: val.tree_sitter.map(Into::into),
            structured_extraction: val.structured_extraction.map(Into::into),
            cancel_token: val.cancel_token.as_ref().map(|v| format!("{v:?}")),
        }
    }
}

#[allow(clippy::needless_update)]
#[allow(clippy::useless_conversion)]
impl From<FileExtractionConfig> for kreuzberg::FileExtractionConfig {
    fn from(val: FileExtractionConfig) -> Self {
        Self {
            enable_quality_processing: val.enable_quality_processing,
            ocr: val.ocr.map(Into::into),
            force_ocr: val.force_ocr,
            force_ocr_pages: val.force_ocr_pages.map(|v| v.into_iter().collect()),
            disable_ocr: val.disable_ocr,
            chunking: val.chunking.map(Into::into),
            content_filter: val.content_filter.map(Into::into),
            images: val.images.map(Into::into),
            token_reduction: val.token_reduction.map(Into::into),
            language_detection: val.language_detection.map(Into::into),
            pages: val.pages.map(Into::into),
            postprocessor: val.postprocessor.map(Into::into),
            result_format: val.result_format.as_deref().map(|s| match s {
                "unified" => kreuzberg::ResultFormat::Unified,
                "element_based" => kreuzberg::ResultFormat::ElementBased,
                _ => Default::default(),
            }),
            output_format: val.output_format.as_deref().map(|s| match s {
                "plain" => kreuzberg::OutputFormat::Plain,
                "markdown" => kreuzberg::OutputFormat::Markdown,
                "djot" => kreuzberg::OutputFormat::Djot,
                "html" => kreuzberg::OutputFormat::Html,
                "json" => kreuzberg::OutputFormat::Json,
                "structured" => kreuzberg::OutputFormat::Structured,
                "custom" => kreuzberg::OutputFormat::Custom(Default::default()),
                _ => Default::default(),
            }),
            include_document_structure: val.include_document_structure,
            timeout_secs: val.timeout_secs.map(|v| v as u64),
            structured_extraction: val.structured_extraction.map(Into::into),
            ..Default::default()
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::FileExtractionConfig> for FileExtractionConfig {
    fn from(val: kreuzberg::FileExtractionConfig) -> Self {
        Self {
            enable_quality_processing: val.enable_quality_processing,
            ocr: val.ocr.map(Into::into),
            force_ocr: val.force_ocr,
            force_ocr_pages: val.force_ocr_pages.map(|v| v.into_iter().collect()),
            disable_ocr: val.disable_ocr,
            chunking: val.chunking.map(Into::into),
            content_filter: val.content_filter.map(Into::into),
            images: val.images.map(Into::into),
            pdf_options: val.pdf_options.map(Into::into),
            token_reduction: val.token_reduction.map(Into::into),
            language_detection: val.language_detection.map(Into::into),
            pages: val.pages.map(Into::into),
            keywords: val.keywords.map(Into::into),
            postprocessor: val.postprocessor.map(Into::into),
            html_options: val.html_options.as_ref().map(|v| format!("{v:?}")),
            result_format: val.result_format.as_ref().map(|v| {
                serde_json::to_value(v)
                    .ok()
                    .and_then(|s| s.as_str().map(String::from))
                    .unwrap_or_default()
            }),
            output_format: val.output_format.as_ref().map(|v| {
                serde_json::to_value(v)
                    .ok()
                    .and_then(|s| s.as_str().map(String::from))
                    .unwrap_or_default()
            }),
            include_document_structure: val.include_document_structure,
            layout: val.layout.map(Into::into),
            timeout_secs: val.timeout_secs.map(|v| v as i64),
            tree_sitter: val.tree_sitter.map(Into::into),
            structured_extraction: val.structured_extraction.map(Into::into),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<BatchBytesItem> for kreuzberg::BatchBytesItem {
    fn from(val: BatchBytesItem) -> Self {
        Self {
            content: val.content.into(),
            mime_type: val.mime_type,
            config: val.config.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::BatchBytesItem> for BatchBytesItem {
    fn from(val: kreuzberg::BatchBytesItem) -> Self {
        Self {
            content: val.content.to_vec().into(),
            mime_type: val.mime_type,
            config: val.config.map(Into::into),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<BatchFileItem> for kreuzberg::BatchFileItem {
    fn from(val: BatchFileItem) -> Self {
        Self {
            path: val.path.into(),
            config: val.config.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::BatchFileItem> for BatchFileItem {
    fn from(val: kreuzberg::BatchFileItem) -> Self {
        Self {
            path: val.path.to_string_lossy().to_string(),
            config: val.config.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ImageExtractionConfig> for kreuzberg::ImageExtractionConfig {
    fn from(val: ImageExtractionConfig) -> Self {
        Self {
            extract_images: val.extract_images,
            target_dpi: val.target_dpi,
            max_image_dimension: val.max_image_dimension,
            inject_placeholders: val.inject_placeholders,
            auto_adjust_dpi: val.auto_adjust_dpi,
            min_dpi: val.min_dpi,
            max_dpi: val.max_dpi,
            max_images_per_page: val.max_images_per_page,
            classify: val.classify,
            include_page_rasters: val.include_page_rasters,
            run_ocr_on_images: val.run_ocr_on_images,
            ocr_text_only: val.ocr_text_only,
            append_ocr_text: val.append_ocr_text,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ImageExtractionConfig> for ImageExtractionConfig {
    fn from(val: kreuzberg::ImageExtractionConfig) -> Self {
        Self {
            extract_images: val.extract_images,
            target_dpi: val.target_dpi,
            max_image_dimension: val.max_image_dimension,
            inject_placeholders: val.inject_placeholders,
            auto_adjust_dpi: val.auto_adjust_dpi,
            min_dpi: val.min_dpi,
            max_dpi: val.max_dpi,
            max_images_per_page: val.max_images_per_page,
            classify: val.classify,
            include_page_rasters: val.include_page_rasters,
            run_ocr_on_images: val.run_ocr_on_images,
            ocr_text_only: val.ocr_text_only,
            append_ocr_text: val.append_ocr_text,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TokenReductionOptions> for kreuzberg::TokenReductionOptions {
    fn from(val: TokenReductionOptions) -> Self {
        Self {
            mode: val.mode,
            preserve_important_words: val.preserve_important_words,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TokenReductionOptions> for TokenReductionOptions {
    fn from(val: kreuzberg::TokenReductionOptions) -> Self {
        Self {
            mode: val.mode,
            preserve_important_words: val.preserve_important_words,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<LanguageDetectionConfig> for kreuzberg::LanguageDetectionConfig {
    fn from(val: LanguageDetectionConfig) -> Self {
        Self {
            enabled: val.enabled,
            min_confidence: val.min_confidence,
            detect_multiple: val.detect_multiple,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LanguageDetectionConfig> for LanguageDetectionConfig {
    fn from(val: kreuzberg::LanguageDetectionConfig) -> Self {
        Self {
            enabled: val.enabled,
            min_confidence: val.min_confidence,
            detect_multiple: val.detect_multiple,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<HtmlOutputConfig> for kreuzberg::HtmlOutputConfig {
    fn from(val: HtmlOutputConfig) -> Self {
        Self {
            css: val.css,
            css_file: val.css_file.map(Into::into),
            theme: match val.theme.as_str() {
                "default" => kreuzberg::HtmlTheme::Default,
                "github" => kreuzberg::HtmlTheme::GitHub,
                "dark" => kreuzberg::HtmlTheme::Dark,
                "light" => kreuzberg::HtmlTheme::Light,
                "unstyled" => kreuzberg::HtmlTheme::Unstyled,
                _ => Default::default(),
            },
            class_prefix: val.class_prefix,
            embed_css: val.embed_css,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HtmlOutputConfig> for HtmlOutputConfig {
    fn from(val: kreuzberg::HtmlOutputConfig) -> Self {
        Self {
            css: val.css,
            css_file: val.css_file.map(|p| p.to_string_lossy().to_string()),
            theme: serde_json::to_value(val.theme)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            class_prefix: val.class_prefix,
            embed_css: val.embed_css,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<LayoutDetectionConfig> for kreuzberg::LayoutDetectionConfig {
    fn from(val: LayoutDetectionConfig) -> Self {
        Self {
            confidence_threshold: val.confidence_threshold,
            apply_heuristics: val.apply_heuristics,
            table_model: match val.table_model.as_str() {
                "tatr" => kreuzberg::TableModel::Tatr,
                "slanet_wired" => kreuzberg::TableModel::SlanetWired,
                "slanet_wireless" => kreuzberg::TableModel::SlanetWireless,
                "slanet_plus" => kreuzberg::TableModel::SlanetPlus,
                "slanet_auto" => kreuzberg::TableModel::SlanetAuto,
                "disabled" => kreuzberg::TableModel::Disabled,
                _ => Default::default(),
            },
            acceleration: val.acceleration.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LayoutDetectionConfig> for LayoutDetectionConfig {
    fn from(val: kreuzberg::LayoutDetectionConfig) -> Self {
        Self {
            confidence_threshold: val.confidence_threshold,
            apply_heuristics: val.apply_heuristics,
            table_model: serde_json::to_value(val.table_model)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            acceleration: val.acceleration.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<LlmConfig> for kreuzberg::LlmConfig {
    fn from(val: LlmConfig) -> Self {
        Self {
            model: val.model,
            api_key: val.api_key,
            base_url: val.base_url,
            timeout_secs: val.timeout_secs.map(|v| v as u64),
            max_retries: val.max_retries,
            temperature: val.temperature,
            max_tokens: val.max_tokens.map(|v| v as u64),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LlmConfig> for LlmConfig {
    fn from(val: kreuzberg::LlmConfig) -> Self {
        Self {
            model: val.model,
            api_key: val.api_key,
            base_url: val.base_url,
            timeout_secs: val.timeout_secs.map(|v| v as i64),
            max_retries: val.max_retries,
            temperature: val.temperature,
            max_tokens: val.max_tokens.map(|v| v as i64),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<StructuredExtractionConfig> for kreuzberg::StructuredExtractionConfig {
    fn from(val: StructuredExtractionConfig) -> Self {
        Self {
            schema: val.schema,
            schema_name: val.schema_name,
            schema_description: val.schema_description,
            strict: val.strict,
            prompt: val.prompt,
            llm: val.llm.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::StructuredExtractionConfig> for StructuredExtractionConfig {
    fn from(val: kreuzberg::StructuredExtractionConfig) -> Self {
        Self {
            schema: val.schema,
            schema_name: val.schema_name,
            schema_description: val.schema_description,
            strict: val.strict,
            prompt: val.prompt,
            llm: val.llm.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrQualityThresholds> for kreuzberg::OcrQualityThresholds {
    fn from(val: OcrQualityThresholds) -> Self {
        Self {
            min_total_non_whitespace: val.min_total_non_whitespace as usize,
            min_non_whitespace_per_page: val.min_non_whitespace_per_page,
            min_meaningful_word_len: val.min_meaningful_word_len as usize,
            min_meaningful_words: val.min_meaningful_words as usize,
            min_alnum_ratio: val.min_alnum_ratio,
            min_garbage_chars: val.min_garbage_chars as usize,
            max_fragmented_word_ratio: val.max_fragmented_word_ratio,
            critical_fragmented_word_ratio: val.critical_fragmented_word_ratio,
            min_avg_word_length: val.min_avg_word_length,
            min_words_for_avg_length_check: val.min_words_for_avg_length_check as usize,
            min_consecutive_repeat_ratio: val.min_consecutive_repeat_ratio,
            min_words_for_repeat_check: val.min_words_for_repeat_check as usize,
            substantive_min_chars: val.substantive_min_chars as usize,
            non_text_min_chars: val.non_text_min_chars as usize,
            alnum_ws_ratio_threshold: val.alnum_ws_ratio_threshold,
            pipeline_min_quality: val.pipeline_min_quality,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrQualityThresholds> for OcrQualityThresholds {
    fn from(val: kreuzberg::OcrQualityThresholds) -> Self {
        Self {
            min_total_non_whitespace: val.min_total_non_whitespace as i64,
            min_non_whitespace_per_page: val.min_non_whitespace_per_page,
            min_meaningful_word_len: val.min_meaningful_word_len as i64,
            min_meaningful_words: val.min_meaningful_words as i64,
            min_alnum_ratio: val.min_alnum_ratio,
            min_garbage_chars: val.min_garbage_chars as i64,
            max_fragmented_word_ratio: val.max_fragmented_word_ratio,
            critical_fragmented_word_ratio: val.critical_fragmented_word_ratio,
            min_avg_word_length: val.min_avg_word_length,
            min_words_for_avg_length_check: val.min_words_for_avg_length_check as i64,
            min_consecutive_repeat_ratio: val.min_consecutive_repeat_ratio,
            min_words_for_repeat_check: val.min_words_for_repeat_check as i64,
            substantive_min_chars: val.substantive_min_chars as i64,
            non_text_min_chars: val.non_text_min_chars as i64,
            alnum_ws_ratio_threshold: val.alnum_ws_ratio_threshold,
            pipeline_min_quality: val.pipeline_min_quality,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrPipelineStage> for kreuzberg::OcrPipelineStage {
    fn from(val: OcrPipelineStage) -> Self {
        Self {
            backend: val.backend,
            priority: val.priority,
            language: val.language,
            tesseract_config: val.tesseract_config.map(Into::into),
            paddle_ocr_config: val.paddle_ocr_config,
            vlm_config: val.vlm_config.map(Into::into),
            backend_options: val.backend_options,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrPipelineStage> for OcrPipelineStage {
    fn from(val: kreuzberg::OcrPipelineStage) -> Self {
        Self {
            backend: val.backend,
            priority: val.priority,
            language: val.language,
            tesseract_config: val.tesseract_config.map(Into::into),
            paddle_ocr_config: val.paddle_ocr_config,
            vlm_config: val.vlm_config.map(Into::into),
            backend_options: val.backend_options,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrPipelineConfig> for kreuzberg::OcrPipelineConfig {
    fn from(val: OcrPipelineConfig) -> Self {
        Self {
            stages: val.stages.into_iter().map(Into::into).collect(),
            quality_thresholds: val.quality_thresholds.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrPipelineConfig> for OcrPipelineConfig {
    fn from(val: kreuzberg::OcrPipelineConfig) -> Self {
        Self {
            stages: val.stages.into_iter().map(Into::into).collect(),
            quality_thresholds: val.quality_thresholds.into(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<OcrConfig> for kreuzberg::OcrConfig {
    fn from(val: OcrConfig) -> Self {
        Self {
            enabled: val.enabled,
            backend: val.backend,
            language: val.language,
            tesseract_config: val.tesseract_config.map(Into::into),
            output_format: val.output_format.as_deref().map(|s| match s {
                "plain" => kreuzberg::OutputFormat::Plain,
                "markdown" => kreuzberg::OutputFormat::Markdown,
                "djot" => kreuzberg::OutputFormat::Djot,
                "html" => kreuzberg::OutputFormat::Html,
                "json" => kreuzberg::OutputFormat::Json,
                "structured" => kreuzberg::OutputFormat::Structured,
                "custom" => kreuzberg::OutputFormat::Custom(Default::default()),
                _ => Default::default(),
            }),
            paddle_ocr_config: val.paddle_ocr_config,
            backend_options: val.backend_options,
            element_config: val.element_config.map(Into::into),
            quality_thresholds: val.quality_thresholds.map(Into::into),
            pipeline: val.pipeline.map(Into::into),
            auto_rotate: val.auto_rotate,
            vlm_config: val.vlm_config.map(Into::into),
            vlm_prompt: val.vlm_prompt,
            acceleration: val.acceleration.map(Into::into),
            tessdata_bytes: val
                .tessdata_bytes
                .map(|m| m.into_iter().map(|(k, v)| (k, v.to_vec().into())).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrConfig> for OcrConfig {
    fn from(val: kreuzberg::OcrConfig) -> Self {
        Self {
            enabled: val.enabled,
            backend: val.backend,
            language: val.language,
            tesseract_config: val.tesseract_config.map(Into::into),
            output_format: val.output_format.as_ref().map(|v| {
                serde_json::to_value(v)
                    .ok()
                    .and_then(|s| s.as_str().map(String::from))
                    .unwrap_or_default()
            }),
            paddle_ocr_config: val.paddle_ocr_config,
            backend_options: val.backend_options,
            element_config: val.element_config.map(Into::into),
            quality_thresholds: val.quality_thresholds.map(Into::into),
            pipeline: val.pipeline.map(Into::into),
            auto_rotate: val.auto_rotate,
            vlm_config: val.vlm_config.map(Into::into),
            vlm_prompt: val.vlm_prompt,
            acceleration: val.acceleration.map(Into::into),
            tessdata_bytes: val
                .tessdata_bytes
                .map(|m| m.into_iter().map(|(k, v)| (k, v.to_vec().into())).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PageConfig> for kreuzberg::PageConfig {
    fn from(val: PageConfig) -> Self {
        Self {
            extract_pages: val.extract_pages,
            insert_page_markers: val.insert_page_markers,
            marker_format: val.marker_format,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PageConfig> for PageConfig {
    fn from(val: kreuzberg::PageConfig) -> Self {
        Self {
            extract_pages: val.extract_pages,
            insert_page_markers: val.insert_page_markers,
            marker_format: val.marker_format,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PdfConfig> for kreuzberg::PdfConfig {
    fn from(val: PdfConfig) -> Self {
        Self {
            extract_images: val.extract_images,
            extract_tables: val.extract_tables,
            passwords: val.passwords.map(|v| v.into_iter().collect()),
            extract_metadata: val.extract_metadata,
            hierarchy: val.hierarchy.map(Into::into),
            extract_annotations: val.extract_annotations,
            top_margin_fraction: val.top_margin_fraction,
            bottom_margin_fraction: val.bottom_margin_fraction,
            allow_single_column_tables: val.allow_single_column_tables,
            ocr_inline_images: val.ocr_inline_images,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PdfConfig> for PdfConfig {
    fn from(val: kreuzberg::PdfConfig) -> Self {
        Self {
            extract_images: val.extract_images,
            extract_tables: val.extract_tables,
            passwords: val.passwords.map(|v| v.into_iter().collect()),
            extract_metadata: val.extract_metadata,
            hierarchy: val.hierarchy.map(Into::into),
            extract_annotations: val.extract_annotations,
            top_margin_fraction: val.top_margin_fraction,
            bottom_margin_fraction: val.bottom_margin_fraction,
            allow_single_column_tables: val.allow_single_column_tables,
            ocr_inline_images: val.ocr_inline_images,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<HierarchyConfig> for kreuzberg::HierarchyConfig {
    fn from(val: HierarchyConfig) -> Self {
        Self {
            enabled: val.enabled,
            k_clusters: val.k_clusters as usize,
            include_bbox: val.include_bbox,
            ocr_coverage_threshold: val.ocr_coverage_threshold,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HierarchyConfig> for HierarchyConfig {
    fn from(val: kreuzberg::HierarchyConfig) -> Self {
        Self {
            enabled: val.enabled,
            k_clusters: val.k_clusters as i64,
            include_bbox: val.include_bbox,
            ocr_coverage_threshold: val.ocr_coverage_threshold,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PostProcessorConfig> for kreuzberg::PostProcessorConfig {
    fn from(val: PostProcessorConfig) -> Self {
        Self {
            enabled: val.enabled,
            enabled_processors: val.enabled_processors.map(|v| v.into_iter().collect()),
            disabled_processors: val.disabled_processors.map(|v| v.into_iter().collect()),
            enabled_set: val.enabled_set.map(|v| v.into_iter().collect()),
            disabled_set: val.disabled_set.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PostProcessorConfig> for PostProcessorConfig {
    fn from(val: kreuzberg::PostProcessorConfig) -> Self {
        Self {
            enabled: val.enabled,
            enabled_processors: val.enabled_processors.map(|v| v.into_iter().collect()),
            disabled_processors: val.disabled_processors.map(|v| v.into_iter().collect()),
            enabled_set: val.enabled_set.map(|v| v.into_iter().collect()),
            disabled_set: val.disabled_set.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ChunkingConfig> for kreuzberg::ChunkingConfig {
    fn from(val: ChunkingConfig) -> Self {
        Self {
            max_characters: val.max_characters as usize,
            overlap: val.overlap as usize,
            trim: val.trim,
            chunker_type: match val.chunker_type.as_str() {
                "text" => kreuzberg::ChunkerType::Text,
                "markdown" => kreuzberg::ChunkerType::Markdown,
                "yaml" => kreuzberg::ChunkerType::Yaml,
                "semantic" => kreuzberg::ChunkerType::Semantic,
                _ => Default::default(),
            },
            embedding: val.embedding.map(Into::into),
            preset: val.preset,
            sizing: val.sizing.into(),
            prepend_heading_context: val.prepend_heading_context,
            topic_threshold: val.topic_threshold,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ChunkingConfig> for ChunkingConfig {
    fn from(val: kreuzberg::ChunkingConfig) -> Self {
        Self {
            max_characters: val.max_characters as i64,
            overlap: val.overlap as i64,
            trim: val.trim,
            chunker_type: serde_json::to_value(val.chunker_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            embedding: val.embedding.map(Into::into),
            preset: val.preset,
            sizing: val.sizing.into(),
            prepend_heading_context: val.prepend_heading_context,
            topic_threshold: val.topic_threshold,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<EmbeddingConfig> for kreuzberg::EmbeddingConfig {
    fn from(val: EmbeddingConfig) -> Self {
        Self {
            model: val.model.into(),
            normalize: val.normalize,
            batch_size: val.batch_size as usize,
            show_download_progress: val.show_download_progress,
            cache_dir: val.cache_dir.map(Into::into),
            acceleration: val.acceleration.map(Into::into),
            max_embed_duration_secs: val.max_embed_duration_secs.map(|v| v as u64),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmbeddingConfig> for EmbeddingConfig {
    fn from(val: kreuzberg::EmbeddingConfig) -> Self {
        Self {
            model: val.model.into(),
            normalize: val.normalize,
            batch_size: val.batch_size as i64,
            show_download_progress: val.show_download_progress,
            cache_dir: val.cache_dir.map(|p| p.to_string_lossy().to_string()),
            acceleration: val.acceleration.map(Into::into),
            max_embed_duration_secs: val.max_embed_duration_secs.map(|v| v as i64),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<TreeSitterConfig> for kreuzberg::TreeSitterConfig {
    fn from(val: TreeSitterConfig) -> Self {
        Self {
            enabled: val.enabled,
            cache_dir: val.cache_dir.map(Into::into),
            languages: val.languages.map(|v| v.into_iter().collect()),
            groups: val.groups.map(|v| v.into_iter().collect()),
            process: val.process.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TreeSitterConfig> for TreeSitterConfig {
    fn from(val: kreuzberg::TreeSitterConfig) -> Self {
        Self {
            enabled: val.enabled,
            cache_dir: val.cache_dir.map(|p| p.to_string_lossy().to_string()),
            languages: val.languages.map(|v| v.into_iter().collect()),
            groups: val.groups.map(|v| v.into_iter().collect()),
            process: val.process.into(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<TreeSitterProcessConfig> for kreuzberg::TreeSitterProcessConfig {
    fn from(val: TreeSitterProcessConfig) -> Self {
        Self {
            structure: val.structure,
            imports: val.imports,
            exports: val.exports,
            comments: val.comments,
            docstrings: val.docstrings,
            symbols: val.symbols,
            diagnostics: val.diagnostics,
            chunk_max_size: val.chunk_max_size.map(|v| v as usize),
            content_mode: match val.content_mode.as_str() {
                "chunks" => kreuzberg::CodeContentMode::Chunks,
                "raw" => kreuzberg::CodeContentMode::Raw,
                "structure" => kreuzberg::CodeContentMode::Structure,
                _ => Default::default(),
            },
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TreeSitterProcessConfig> for TreeSitterProcessConfig {
    fn from(val: kreuzberg::TreeSitterProcessConfig) -> Self {
        Self {
            structure: val.structure,
            imports: val.imports,
            exports: val.exports,
            comments: val.comments,
            docstrings: val.docstrings,
            symbols: val.symbols,
            diagnostics: val.diagnostics,
            chunk_max_size: val.chunk_max_size.map(|v| v as i64),
            content_mode: serde_json::to_value(val.content_mode)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::SupportedFormat> for SupportedFormat {
    fn from(val: kreuzberg::SupportedFormat) -> Self {
        Self {
            extension: val.extension,
            mime_type: val.mime_type,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ServerConfig> for kreuzberg::ServerConfig {
    fn from(val: ServerConfig) -> Self {
        Self {
            host: val.host,
            port: val.port,
            cors_origins: val.cors_origins.into_iter().collect(),
            max_request_body_bytes: val.max_request_body_bytes as usize,
            max_multipart_field_bytes: val.max_multipart_field_bytes as usize,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ServerConfig> for ServerConfig {
    fn from(val: kreuzberg::ServerConfig) -> Self {
        Self {
            host: val.host,
            port: val.port,
            cors_origins: val.cors_origins.into_iter().collect(),
            max_request_body_bytes: val.max_request_body_bytes as i64,
            max_multipart_field_bytes: val.max_multipart_field_bytes as i64,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::extraction::structured::StructuredDataResult> for StructuredDataResult {
    fn from(val: kreuzberg::extraction::structured::StructuredDataResult) -> Self {
        Self {
            content: val.content,
            format: val.format.to_string(),
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            text_fields: val.text_fields.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DocxAppProperties> for kreuzberg::DocxAppProperties {
    fn from(val: DocxAppProperties) -> Self {
        Self {
            application: val.application,
            app_version: val.app_version,
            template: val.template,
            total_time: val.total_time,
            pages: val.pages,
            words: val.words,
            characters: val.characters,
            characters_with_spaces: val.characters_with_spaces,
            lines: val.lines,
            paragraphs: val.paragraphs,
            company: val.company,
            doc_security: val.doc_security,
            scale_crop: val.scale_crop,
            links_up_to_date: val.links_up_to_date,
            shared_doc: val.shared_doc,
            hyperlinks_changed: val.hyperlinks_changed,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DocxAppProperties> for DocxAppProperties {
    fn from(val: kreuzberg::DocxAppProperties) -> Self {
        Self {
            application: val.application,
            app_version: val.app_version,
            template: val.template,
            total_time: val.total_time,
            pages: val.pages,
            words: val.words,
            characters: val.characters,
            characters_with_spaces: val.characters_with_spaces,
            lines: val.lines,
            paragraphs: val.paragraphs,
            company: val.company,
            doc_security: val.doc_security,
            scale_crop: val.scale_crop,
            links_up_to_date: val.links_up_to_date,
            shared_doc: val.shared_doc,
            hyperlinks_changed: val.hyperlinks_changed,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::extraction::office_metadata::app_properties::XlsxAppProperties> for XlsxAppProperties {
    fn from(val: kreuzberg::extraction::office_metadata::app_properties::XlsxAppProperties) -> Self {
        Self {
            application: val.application,
            app_version: val.app_version,
            doc_security: val.doc_security,
            scale_crop: val.scale_crop,
            links_up_to_date: val.links_up_to_date,
            shared_doc: val.shared_doc,
            hyperlinks_changed: val.hyperlinks_changed,
            company: val.company,
            worksheet_names: val.worksheet_names.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::extraction::office_metadata::app_properties::PptxAppProperties> for PptxAppProperties {
    fn from(val: kreuzberg::extraction::office_metadata::app_properties::PptxAppProperties) -> Self {
        Self {
            application: val.application,
            app_version: val.app_version,
            total_time: val.total_time,
            company: val.company,
            doc_security: val.doc_security,
            scale_crop: val.scale_crop,
            links_up_to_date: val.links_up_to_date,
            shared_doc: val.shared_doc,
            hyperlinks_changed: val.hyperlinks_changed,
            slides: val.slides,
            notes: val.notes,
            hidden_slides: val.hidden_slides,
            multimedia_clips: val.multimedia_clips,
            presentation_format: val.presentation_format,
            slide_titles: val.slide_titles.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<CoreProperties> for kreuzberg::CoreProperties {
    fn from(val: CoreProperties) -> Self {
        Self {
            title: val.title,
            subject: val.subject,
            creator: val.creator,
            keywords: val.keywords,
            description: val.description,
            last_modified_by: val.last_modified_by,
            revision: val.revision,
            created: val.created,
            modified: val.modified,
            category: val.category,
            content_status: val.content_status,
            language: val.language,
            identifier: val.identifier,
            version: val.version,
            last_printed: val.last_printed,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::CoreProperties> for CoreProperties {
    fn from(val: kreuzberg::CoreProperties) -> Self {
        Self {
            title: val.title,
            subject: val.subject,
            creator: val.creator,
            keywords: val.keywords,
            description: val.description,
            last_modified_by: val.last_modified_by,
            revision: val.revision,
            created: val.created,
            modified: val.modified,
            category: val.category,
            content_status: val.content_status,
            language: val.language,
            identifier: val.identifier,
            version: val.version,
            last_printed: val.last_printed,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<SecurityLimits> for kreuzberg::SecurityLimits {
    fn from(val: SecurityLimits) -> Self {
        Self {
            max_archive_size: val.max_archive_size as usize,
            max_compression_ratio: val.max_compression_ratio as usize,
            max_files_in_archive: val.max_files_in_archive as usize,
            max_nesting_depth: val.max_nesting_depth as usize,
            max_entity_length: val.max_entity_length as usize,
            max_content_size: val.max_content_size as usize,
            max_iterations: val.max_iterations as usize,
            max_xml_depth: val.max_xml_depth as usize,
            max_table_cells: val.max_table_cells as usize,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::SecurityLimits> for SecurityLimits {
    fn from(val: kreuzberg::SecurityLimits) -> Self {
        Self {
            max_archive_size: val.max_archive_size as i64,
            max_compression_ratio: val.max_compression_ratio as i64,
            max_files_in_archive: val.max_files_in_archive as i64,
            max_nesting_depth: val.max_nesting_depth as i64,
            max_entity_length: val.max_entity_length as i64,
            max_content_size: val.max_content_size as i64,
            max_iterations: val.max_iterations as i64,
            max_xml_depth: val.max_xml_depth as i64,
            max_table_cells: val.max_table_cells as i64,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<TokenReductionConfig> for kreuzberg::TokenReductionConfig {
    fn from(val: TokenReductionConfig) -> Self {
        Self {
            level: match val.level.as_str() {
                "Off" | "off" => kreuzberg::ReductionLevel::Off,
                "Light" | "light" => kreuzberg::ReductionLevel::Light,
                "Moderate" | "moderate" => kreuzberg::ReductionLevel::Moderate,
                "Aggressive" | "aggressive" => kreuzberg::ReductionLevel::Aggressive,
                "Maximum" | "maximum" => kreuzberg::ReductionLevel::Maximum,
                _ => Default::default(),
            },
            language_hint: val.language_hint,
            preserve_markdown: val.preserve_markdown,
            preserve_code: val.preserve_code,
            semantic_threshold: val.semantic_threshold,
            enable_parallel: val.enable_parallel,
            use_simd: val.use_simd,
            custom_stopwords: val.custom_stopwords.map(|m| m.into_iter().collect()),
            preserve_patterns: val.preserve_patterns.into_iter().collect(),
            target_reduction: val.target_reduction,
            enable_semantic_clustering: val.enable_semantic_clustering,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TokenReductionConfig> for TokenReductionConfig {
    fn from(val: kreuzberg::TokenReductionConfig) -> Self {
        Self {
            level: serde_json::to_value(val.level)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            language_hint: val.language_hint,
            preserve_markdown: val.preserve_markdown,
            preserve_code: val.preserve_code,
            semantic_threshold: val.semantic_threshold,
            enable_parallel: val.enable_parallel,
            use_simd: val.use_simd,
            custom_stopwords: val.custom_stopwords.map(|m| m.into_iter().collect()),
            preserve_patterns: val.preserve_patterns.into_iter().collect(),
            target_reduction: val.target_reduction,
            enable_semantic_clustering: val.enable_semantic_clustering,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<PdfAnnotation> for kreuzberg::PdfAnnotation {
    fn from(val: PdfAnnotation) -> Self {
        Self {
            annotation_type: match val.annotation_type.as_str() {
                "text" => kreuzberg::PdfAnnotationType::Text,
                "highlight" => kreuzberg::PdfAnnotationType::Highlight,
                "link" => kreuzberg::PdfAnnotationType::Link,
                "stamp" => kreuzberg::PdfAnnotationType::Stamp,
                "underline" => kreuzberg::PdfAnnotationType::Underline,
                "strike_out" => kreuzberg::PdfAnnotationType::StrikeOut,
                "other" => kreuzberg::PdfAnnotationType::Other,
                _ => kreuzberg::PdfAnnotationType::Text,
            },
            content: val.content,
            page_number: val.page_number,
            bounding_box: val.bounding_box.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PdfAnnotation> for PdfAnnotation {
    fn from(val: kreuzberg::PdfAnnotation) -> Self {
        Self {
            annotation_type: serde_json::to_value(val.annotation_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            content: val.content,
            page_number: val.page_number,
            bounding_box: val.bounding_box.map(Into::into),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<DjotContent> for kreuzberg::DjotContent {
    fn from(val: DjotContent) -> Self {
        Self {
            plain_text: val.plain_text,
            blocks: val.blocks.into_iter().map(Into::into).collect(),
            metadata: val.metadata.into(),
            tables: val.tables.into_iter().map(Into::into).collect(),
            images: val.images.into_iter().map(Into::into).collect(),
            links: val.links.into_iter().map(Into::into).collect(),
            footnotes: val.footnotes.into_iter().map(Into::into).collect(),
            attributes: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DjotContent> for DjotContent {
    fn from(val: kreuzberg::DjotContent) -> Self {
        Self {
            plain_text: val.plain_text,
            blocks: val.blocks.into_iter().map(Into::into).collect(),
            metadata: val.metadata.into(),
            tables: val.tables.into_iter().map(Into::into).collect(),
            images: val.images.into_iter().map(Into::into).collect(),
            links: val.links.into_iter().map(Into::into).collect(),
            footnotes: val.footnotes.into_iter().map(Into::into).collect(),
            attributes: val.attributes.iter().map(|i| format!("{:?}", i)).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<FormattedBlock> for kreuzberg::FormattedBlock {
    fn from(val: FormattedBlock) -> Self {
        Self {
            block_type: match val.block_type.as_str() {
                "paragraph" => kreuzberg::BlockType::Paragraph,
                "heading" => kreuzberg::BlockType::Heading,
                "blockquote" => kreuzberg::BlockType::Blockquote,
                "code_block" => kreuzberg::BlockType::CodeBlock,
                "list_item" => kreuzberg::BlockType::ListItem,
                "ordered_list" => kreuzberg::BlockType::OrderedList,
                "bullet_list" => kreuzberg::BlockType::BulletList,
                "task_list" => kreuzberg::BlockType::TaskList,
                "definition_list" => kreuzberg::BlockType::DefinitionList,
                "definition_term" => kreuzberg::BlockType::DefinitionTerm,
                "definition_description" => kreuzberg::BlockType::DefinitionDescription,
                "div" => kreuzberg::BlockType::Div,
                "section" => kreuzberg::BlockType::Section,
                "thematic_break" => kreuzberg::BlockType::ThematicBreak,
                "raw_block" => kreuzberg::BlockType::RawBlock,
                "math_display" => kreuzberg::BlockType::MathDisplay,
                _ => kreuzberg::BlockType::Paragraph,
            },
            level: val.level.map(|v| v as usize),
            inline_content: val.inline_content.into_iter().map(Into::into).collect(),
            attributes: Default::default(),
            language: val.language,
            code: val.code,
            children: val.children.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::FormattedBlock> for FormattedBlock {
    fn from(val: kreuzberg::FormattedBlock) -> Self {
        Self {
            block_type: serde_json::to_value(val.block_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            level: val.level.map(|v| v as i64),
            inline_content: val.inline_content.into_iter().map(Into::into).collect(),
            attributes: val.attributes.as_ref().map(|v| format!("{v:?}")),
            language: val.language,
            code: val.code,
            children: val.children.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<InlineElement> for kreuzberg::InlineElement {
    fn from(val: InlineElement) -> Self {
        Self {
            element_type: match val.element_type.as_str() {
                "text" => kreuzberg::InlineType::Text,
                "strong" => kreuzberg::InlineType::Strong,
                "emphasis" => kreuzberg::InlineType::Emphasis,
                "highlight" => kreuzberg::InlineType::Highlight,
                "subscript" => kreuzberg::InlineType::Subscript,
                "superscript" => kreuzberg::InlineType::Superscript,
                "insert" => kreuzberg::InlineType::Insert,
                "delete" => kreuzberg::InlineType::Delete,
                "code" => kreuzberg::InlineType::Code,
                "link" => kreuzberg::InlineType::Link,
                "image" => kreuzberg::InlineType::Image,
                "span" => kreuzberg::InlineType::Span,
                "math" => kreuzberg::InlineType::Math,
                "raw_inline" => kreuzberg::InlineType::RawInline,
                "footnote_ref" => kreuzberg::InlineType::FootnoteRef,
                "symbol" => kreuzberg::InlineType::Symbol,
                _ => kreuzberg::InlineType::Text,
            },
            content: val.content,
            attributes: Default::default(),
            metadata: val
                .metadata
                .map(|m| m.into_iter().map(|(k, v)| (k.into(), v.into())).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::InlineElement> for InlineElement {
    fn from(val: kreuzberg::InlineElement) -> Self {
        Self {
            element_type: serde_json::to_value(val.element_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            content: val.content,
            attributes: val.attributes.as_ref().map(|v| format!("{v:?}")),
            metadata: val
                .metadata
                .map(|m| m.into_iter().map(|(k, v)| (k.into(), v.into())).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DjotImage> for kreuzberg::DjotImage {
    fn from(val: DjotImage) -> Self {
        Self {
            src: val.src,
            alt: val.alt,
            title: val.title,
            attributes: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DjotImage> for DjotImage {
    fn from(val: kreuzberg::DjotImage) -> Self {
        Self {
            src: val.src,
            alt: val.alt,
            title: val.title,
            attributes: val.attributes.as_ref().map(|v| format!("{v:?}")),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DjotLink> for kreuzberg::DjotLink {
    fn from(val: DjotLink) -> Self {
        Self {
            url: val.url,
            text: val.text,
            title: val.title,
            attributes: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DjotLink> for DjotLink {
    fn from(val: kreuzberg::DjotLink) -> Self {
        Self {
            url: val.url,
            text: val.text,
            title: val.title,
            attributes: val.attributes.as_ref().map(|v| format!("{v:?}")),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<Footnote> for kreuzberg::Footnote {
    fn from(val: Footnote) -> Self {
        Self {
            label: val.label,
            content: val.content.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::Footnote> for Footnote {
    fn from(val: kreuzberg::Footnote) -> Self {
        Self {
            label: val.label,
            content: val.content.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<DocumentStructure> for kreuzberg::DocumentStructure {
    fn from(val: DocumentStructure) -> Self {
        Self {
            nodes: val.nodes.into_iter().map(Into::into).collect(),
            source_format: val.source_format,
            relationships: val.relationships.into_iter().map(Into::into).collect(),
            node_types: val.node_types.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DocumentStructure> for DocumentStructure {
    fn from(val: kreuzberg::DocumentStructure) -> Self {
        Self {
            nodes: val.nodes.into_iter().map(Into::into).collect(),
            source_format: val.source_format,
            relationships: val.relationships.into_iter().map(Into::into).collect(),
            node_types: val.node_types.into_iter().collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<DocumentRelationship> for kreuzberg::DocumentRelationship {
    fn from(val: DocumentRelationship) -> Self {
        Self {
            source: kreuzberg::NodeIndex(val.source),
            target: kreuzberg::NodeIndex(val.target),
            kind: match val.kind.as_str() {
                "footnote_reference" => kreuzberg::RelationshipKind::FootnoteReference,
                "citation_reference" => kreuzberg::RelationshipKind::CitationReference,
                "internal_link" => kreuzberg::RelationshipKind::InternalLink,
                "caption" => kreuzberg::RelationshipKind::Caption,
                "label" => kreuzberg::RelationshipKind::Label,
                "toc_entry" => kreuzberg::RelationshipKind::TocEntry,
                "cross_reference" => kreuzberg::RelationshipKind::CrossReference,
                _ => kreuzberg::RelationshipKind::FootnoteReference,
            },
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DocumentRelationship> for DocumentRelationship {
    fn from(val: kreuzberg::DocumentRelationship) -> Self {
        Self {
            source: val.source.0,
            target: val.target.0,
            kind: serde_json::to_value(val.kind)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<DocumentNode> for kreuzberg::DocumentNode {
    fn from(val: DocumentNode) -> Self {
        Self {
            id: Default::default(),
            content: val.content.into(),
            parent: (val.parent).map(kreuzberg::NodeIndex),
            children: (val.children.into_iter().collect::<Vec<_>>())
                .into_iter()
                .map(kreuzberg::NodeIndex)
                .collect(),
            content_layer: match val.content_layer.as_str() {
                "body" => kreuzberg::ContentLayer::Body,
                "header" => kreuzberg::ContentLayer::Header,
                "footer" => kreuzberg::ContentLayer::Footer,
                "footnote" => kreuzberg::ContentLayer::Footnote,
                _ => Default::default(),
            },
            page: val.page,
            page_end: val.page_end,
            bbox: val.bbox.map(Into::into),
            annotations: val.annotations.into_iter().map(Into::into).collect(),
            attributes: val
                .attributes
                .map(|m| m.into_iter().map(|(k, v)| (k.into(), v.into())).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DocumentNode> for DocumentNode {
    fn from(val: kreuzberg::DocumentNode) -> Self {
        Self {
            id: format!("{:?}", val.id),
            content: val.content.into(),
            parent: val.parent.map(|v| v.0),
            children: val
                .children
                .iter()
                .map(|v| v.0)
                .collect::<Vec<_>>()
                .into_iter()
                .collect(),
            content_layer: serde_json::to_value(val.content_layer)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            page: val.page,
            page_end: val.page_end,
            bbox: val.bbox.map(Into::into),
            annotations: val.annotations.into_iter().map(Into::into).collect(),
            attributes: val
                .attributes
                .map(|m| m.into_iter().map(|(k, v)| (k.into(), v.into())).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TableGrid> for kreuzberg::TableGrid {
    fn from(val: TableGrid) -> Self {
        Self {
            rows: val.rows,
            cols: val.cols,
            cells: val.cells.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TableGrid> for TableGrid {
    fn from(val: kreuzberg::TableGrid) -> Self {
        Self {
            rows: val.rows,
            cols: val.cols,
            cells: val.cells.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<GridCell> for kreuzberg::GridCell {
    fn from(val: GridCell) -> Self {
        Self {
            content: val.content,
            row: val.row,
            col: val.col,
            row_span: val.row_span,
            col_span: val.col_span,
            is_header: val.is_header,
            bbox: val.bbox.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::GridCell> for GridCell {
    fn from(val: kreuzberg::GridCell) -> Self {
        Self {
            content: val.content,
            row: val.row,
            col: val.col,
            row_span: val.row_span,
            col_span: val.col_span,
            is_header: val.is_header,
            bbox: val.bbox.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TextAnnotation> for kreuzberg::TextAnnotation {
    fn from(val: TextAnnotation) -> Self {
        Self {
            start: val.start,
            end: val.end,
            kind: val.kind.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TextAnnotation> for TextAnnotation {
    fn from(val: kreuzberg::TextAnnotation) -> Self {
        Self {
            start: val.start,
            end: val.end,
            kind: val.kind.into(),
        }
    }
}

#[allow(clippy::needless_update)]
#[allow(clippy::useless_conversion)]
impl From<ExtractionResult> for kreuzberg::ExtractionResult {
    fn from(val: ExtractionResult) -> Self {
        Self {
            content: val.content,
            mime_type: val.mime_type.into(),
            metadata: val.metadata.into(),
            extraction_method: val.extraction_method.as_deref().map(|s| match s {
                "native" => kreuzberg::ExtractionMethod::Native,
                "ocr" => kreuzberg::ExtractionMethod::Ocr,
                "mixed" => kreuzberg::ExtractionMethod::Mixed,
                _ => kreuzberg::ExtractionMethod::Native,
            }),
            tables: val.tables.into_iter().map(Into::into).collect(),
            detected_languages: val.detected_languages.map(|v| v.into_iter().collect()),
            chunks: val.chunks.map(|v| v.into_iter().map(Into::into).collect()),
            images: val.images.map(|v| v.into_iter().map(Into::into).collect()),
            pages: val.pages.map(|v| v.into_iter().map(Into::into).collect()),
            elements: val.elements.map(|v| v.into_iter().map(Into::into).collect()),
            djot_content: val.djot_content.map(Into::into),
            ocr_elements: val.ocr_elements.map(|v| v.into_iter().map(Into::into).collect()),
            document: val.document.map(Into::into),
            quality_score: val.quality_score,
            processing_warnings: val.processing_warnings.into_iter().map(Into::into).collect(),
            annotations: val.annotations.map(|v| v.into_iter().map(Into::into).collect()),
            children: val.children.map(|v| v.into_iter().map(Into::into).collect()),
            uris: val.uris.map(|v| v.into_iter().map(Into::into).collect()),
            revisions: val.revisions.map(|v| v.into_iter().map(Into::into).collect()),
            structured_output: val.structured_output,
            llm_usage: val.llm_usage.map(|v| v.into_iter().map(Into::into).collect()),
            formatted_content: val.formatted_content,
            ocr_internal_document: Default::default(),
            ..Default::default()
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExtractionResult> for ExtractionResult {
    fn from(val: kreuzberg::ExtractionResult) -> Self {
        Self {
            content: val.content,
            mime_type: val.mime_type.to_string(),
            metadata: val.metadata.into(),
            extraction_method: val.extraction_method.as_ref().map(|v| {
                serde_json::to_value(v)
                    .ok()
                    .and_then(|s| s.as_str().map(String::from))
                    .unwrap_or_default()
            }),
            tables: val.tables.into_iter().map(Into::into).collect(),
            detected_languages: val.detected_languages.map(|v| v.into_iter().collect()),
            chunks: val.chunks.map(|v| v.into_iter().map(Into::into).collect()),
            images: val.images.map(|v| v.into_iter().map(Into::into).collect()),
            pages: val.pages.map(|v| v.into_iter().map(Into::into).collect()),
            elements: val.elements.map(|v| v.into_iter().map(Into::into).collect()),
            djot_content: val.djot_content.map(Into::into),
            ocr_elements: val.ocr_elements.map(|v| v.into_iter().map(Into::into).collect()),
            document: val.document.map(Into::into),
            extracted_keywords: val.extracted_keywords.map(|v| v.into_iter().map(Into::into).collect()),
            quality_score: val.quality_score,
            processing_warnings: val.processing_warnings.into_iter().map(Into::into).collect(),
            annotations: val.annotations.map(|v| v.into_iter().map(Into::into).collect()),
            children: val.children.map(|v| v.into_iter().map(Into::into).collect()),
            uris: val.uris.map(|v| v.into_iter().map(Into::into).collect()),
            revisions: val.revisions.map(|v| v.into_iter().map(Into::into).collect()),
            structured_output: val.structured_output,
            code_intelligence: val.code_intelligence,
            llm_usage: val.llm_usage.map(|v| v.into_iter().map(Into::into).collect()),
            formatted_content: val.formatted_content,
            ocr_internal_document: val.ocr_internal_document.as_ref().map(|v| format!("{v:?}")),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ArchiveEntry> for kreuzberg::ArchiveEntry {
    fn from(val: ArchiveEntry) -> Self {
        Self {
            path: val.path,
            mime_type: val.mime_type,
            result: Box::new(val.result.into()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ArchiveEntry> for ArchiveEntry {
    fn from(val: kreuzberg::ArchiveEntry) -> Self {
        Self {
            path: val.path,
            mime_type: val.mime_type,
            result: (*val.result).into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ProcessingWarning> for kreuzberg::ProcessingWarning {
    fn from(val: ProcessingWarning) -> Self {
        Self {
            source: val.source.into(),
            message: val.message.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ProcessingWarning> for ProcessingWarning {
    fn from(val: kreuzberg::ProcessingWarning) -> Self {
        Self {
            source: val.source.to_string(),
            message: val.message.to_string(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<LlmUsage> for kreuzberg::LlmUsage {
    fn from(val: LlmUsage) -> Self {
        Self {
            model: val.model,
            source: val.source,
            input_tokens: val.input_tokens.map(|v| v as u64),
            output_tokens: val.output_tokens.map(|v| v as u64),
            total_tokens: val.total_tokens.map(|v| v as u64),
            estimated_cost: val.estimated_cost,
            finish_reason: val.finish_reason,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LlmUsage> for LlmUsage {
    fn from(val: kreuzberg::LlmUsage) -> Self {
        Self {
            model: val.model,
            source: val.source,
            input_tokens: val.input_tokens.map(|v| v as i64),
            output_tokens: val.output_tokens.map(|v| v as i64),
            total_tokens: val.total_tokens.map(|v| v as i64),
            estimated_cost: val.estimated_cost,
            finish_reason: val.finish_reason,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<Chunk> for kreuzberg::Chunk {
    fn from(val: Chunk) -> Self {
        Self {
            content: val.content,
            chunk_type: match val.chunk_type.as_str() {
                "heading" => kreuzberg::ChunkType::Heading,
                "party_list" => kreuzberg::ChunkType::PartyList,
                "definitions" => kreuzberg::ChunkType::Definitions,
                "operative_clause" => kreuzberg::ChunkType::OperativeClause,
                "signature_block" => kreuzberg::ChunkType::SignatureBlock,
                "schedule" => kreuzberg::ChunkType::Schedule,
                "table_like" => kreuzberg::ChunkType::TableLike,
                "formula" => kreuzberg::ChunkType::Formula,
                "code_block" => kreuzberg::ChunkType::CodeBlock,
                "image" => kreuzberg::ChunkType::Image,
                "org_chart" => kreuzberg::ChunkType::OrgChart,
                "diagram" => kreuzberg::ChunkType::Diagram,
                "unknown" => kreuzberg::ChunkType::Unknown,
                _ => Default::default(),
            },
            embedding: val.embedding.map(|v| v.into_iter().collect()),
            metadata: val.metadata.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::Chunk> for Chunk {
    fn from(val: kreuzberg::Chunk) -> Self {
        Self {
            content: val.content,
            chunk_type: serde_json::to_value(val.chunk_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            embedding: val.embedding.map(|v| v.into_iter().collect()),
            metadata: val.metadata.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<HeadingContext> for kreuzberg::HeadingContext {
    fn from(val: HeadingContext) -> Self {
        Self {
            headings: val.headings.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HeadingContext> for HeadingContext {
    fn from(val: kreuzberg::HeadingContext) -> Self {
        Self {
            headings: val.headings.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<HeadingLevel> for kreuzberg::HeadingLevel {
    fn from(val: HeadingLevel) -> Self {
        Self {
            level: val.level,
            text: val.text,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HeadingLevel> for HeadingLevel {
    fn from(val: kreuzberg::HeadingLevel) -> Self {
        Self {
            level: val.level,
            text: val.text,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ChunkMetadata> for kreuzberg::ChunkMetadata {
    fn from(val: ChunkMetadata) -> Self {
        Self {
            byte_start: val.byte_start as usize,
            byte_end: val.byte_end as usize,
            token_count: val.token_count.map(|v| v as usize),
            chunk_index: val.chunk_index as usize,
            total_chunks: val.total_chunks as usize,
            first_page: val.first_page,
            last_page: val.last_page,
            heading_context: val.heading_context.map(Into::into),
            image_indices: val.image_indices.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ChunkMetadata> for ChunkMetadata {
    fn from(val: kreuzberg::ChunkMetadata) -> Self {
        Self {
            byte_start: val.byte_start as i64,
            byte_end: val.byte_end as i64,
            token_count: val.token_count.map(|v| v as i64),
            chunk_index: val.chunk_index as i64,
            total_chunks: val.total_chunks as i64,
            first_page: val.first_page,
            last_page: val.last_page,
            heading_context: val.heading_context.map(Into::into),
            image_indices: val.image_indices.into_iter().collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ExtractedImage> for kreuzberg::ExtractedImage {
    fn from(val: ExtractedImage) -> Self {
        Self {
            data: val.data.into(),
            format: val.format.into(),
            image_index: val.image_index,
            page_number: val.page_number,
            width: val.width,
            height: val.height,
            colorspace: val.colorspace,
            bits_per_component: val.bits_per_component,
            is_mask: val.is_mask,
            description: val.description,
            ocr_result: val.ocr_result.map(Into::into).map(Box::new),
            bounding_box: val.bounding_box.map(Into::into),
            source_path: val.source_path,
            image_kind: val.image_kind.as_deref().map(|s| match s {
                "photograph" => kreuzberg::ImageKind::Photograph,
                "diagram" => kreuzberg::ImageKind::Diagram,
                "chart" => kreuzberg::ImageKind::Chart,
                "drawing" => kreuzberg::ImageKind::Drawing,
                "text_block" => kreuzberg::ImageKind::TextBlock,
                "decoration" => kreuzberg::ImageKind::Decoration,
                "logo" => kreuzberg::ImageKind::Logo,
                "icon" => kreuzberg::ImageKind::Icon,
                "tile_fragment" => kreuzberg::ImageKind::TileFragment,
                "mask" => kreuzberg::ImageKind::Mask,
                "page_raster" => kreuzberg::ImageKind::PageRaster,
                "unknown" => kreuzberg::ImageKind::Unknown,
                _ => kreuzberg::ImageKind::Photograph,
            }),
            kind_confidence: val.kind_confidence,
            cluster_id: val.cluster_id,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExtractedImage> for ExtractedImage {
    fn from(val: kreuzberg::ExtractedImage) -> Self {
        Self {
            data: val.data.to_vec().into(),
            format: val.format.to_string(),
            image_index: val.image_index,
            page_number: val.page_number,
            width: val.width,
            height: val.height,
            colorspace: val.colorspace,
            bits_per_component: val.bits_per_component,
            is_mask: val.is_mask,
            description: val.description,
            ocr_result: val.ocr_result.map(|v| (*v).into()),
            bounding_box: val.bounding_box.map(Into::into),
            source_path: val.source_path,
            image_kind: val.image_kind.as_ref().map(|v| {
                serde_json::to_value(v)
                    .ok()
                    .and_then(|s| s.as_str().map(String::from))
                    .unwrap_or_default()
            }),
            kind_confidence: val.kind_confidence,
            cluster_id: val.cluster_id,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<BoundingBox> for kreuzberg::BoundingBox {
    fn from(val: BoundingBox) -> Self {
        Self {
            x0: val.x0,
            y0: val.y0,
            x1: val.x1,
            y1: val.y1,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::BoundingBox> for BoundingBox {
    fn from(val: kreuzberg::BoundingBox) -> Self {
        Self {
            x0: val.x0,
            y0: val.y0,
            x1: val.x1,
            y1: val.y1,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ElementMetadata> for kreuzberg::ElementMetadata {
    fn from(val: ElementMetadata) -> Self {
        Self {
            page_number: val.page_number,
            filename: val.filename,
            coordinates: val.coordinates.map(Into::into),
            element_index: val.element_index.map(|v| v as usize),
            additional: val.additional.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ElementMetadata> for ElementMetadata {
    fn from(val: kreuzberg::ElementMetadata) -> Self {
        Self {
            page_number: val.page_number,
            filename: val.filename,
            coordinates: val.coordinates.map(Into::into),
            element_index: val.element_index.map(|v| v as i64),
            additional: val.additional.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<Element> for kreuzberg::Element {
    fn from(val: Element) -> Self {
        Self {
            element_id: Default::default(),
            element_type: match val.element_type.as_str() {
                "title" => kreuzberg::ElementType::Title,
                "narrative_text" => kreuzberg::ElementType::NarrativeText,
                "heading" => kreuzberg::ElementType::Heading,
                "list_item" => kreuzberg::ElementType::ListItem,
                "table" => kreuzberg::ElementType::Table,
                "image" => kreuzberg::ElementType::Image,
                "page_break" => kreuzberg::ElementType::PageBreak,
                "code_block" => kreuzberg::ElementType::CodeBlock,
                "block_quote" => kreuzberg::ElementType::BlockQuote,
                "footer" => kreuzberg::ElementType::Footer,
                "header" => kreuzberg::ElementType::Header,
                _ => kreuzberg::ElementType::Title,
            },
            text: val.text,
            metadata: val.metadata.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::Element> for Element {
    fn from(val: kreuzberg::Element) -> Self {
        Self {
            element_id: format!("{:?}", val.element_id),
            element_type: serde_json::to_value(val.element_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            text: val.text,
            metadata: val.metadata.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExcelWorkbook> for ExcelWorkbook {
    fn from(val: kreuzberg::ExcelWorkbook) -> Self {
        Self {
            sheets: val.sheets.into_iter().map(Into::into).collect(),
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            revisions: val.revisions.map(|v| v.into_iter().map(Into::into).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExcelSheet> for ExcelSheet {
    fn from(val: kreuzberg::ExcelSheet) -> Self {
        Self {
            name: val.name,
            markdown: val.markdown,
            row_count: val.row_count as i64,
            col_count: val.col_count as i64,
            cell_count: val.cell_count as i64,
            table_cells: val.table_cells.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::XmlExtractionResult> for XmlExtractionResult {
    fn from(val: kreuzberg::XmlExtractionResult) -> Self {
        Self {
            content: val.content,
            element_count: val.element_count as i64,
            unique_elements: val.unique_elements.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TextExtractionResult> for TextExtractionResult {
    fn from(val: kreuzberg::TextExtractionResult) -> Self {
        Self {
            content: val.content,
            line_count: val.line_count as i64,
            word_count: val.word_count as i64,
            character_count: val.character_count as i64,
            headers: val.headers.map(|v| v.into_iter().collect()),
            links: val.links.as_ref().map(|v| {
                v.iter()
                    .map(|(a, b)| vec![a.to_string(), b.to_string()])
                    .collect::<Vec<Vec<String>>>()
            }),
            code_blocks: val.code_blocks.as_ref().map(|v| {
                v.iter()
                    .map(|(a, b)| vec![a.to_string(), b.to_string()])
                    .collect::<Vec<Vec<String>>>()
            }),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PptxExtractionResult> for PptxExtractionResult {
    fn from(val: kreuzberg::PptxExtractionResult) -> Self {
        Self {
            content: val.content,
            metadata: val.metadata.into(),
            slide_count: val.slide_count as i64,
            image_count: val.image_count as i64,
            table_count: val.table_count as i64,
            images: val.images.into_iter().map(Into::into).collect(),
            page_structure: val.page_structure.map(Into::into),
            page_contents: val.page_contents.map(|v| v.into_iter().map(Into::into).collect()),
            document: val.document.map(Into::into),
            hyperlinks: val.hyperlinks.iter().map(|i| format!("{:?}", i)).collect(),
            office_metadata: val
                .office_metadata
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
            revisions: val.revisions.map(|v| v.into_iter().map(Into::into).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmailExtractionResult> for EmailExtractionResult {
    fn from(val: kreuzberg::EmailExtractionResult) -> Self {
        Self {
            subject: val.subject,
            from_email: val.from_email,
            to_emails: val.to_emails.into_iter().collect(),
            cc_emails: val.cc_emails.into_iter().collect(),
            bcc_emails: val.bcc_emails.into_iter().collect(),
            date: val.date,
            message_id: val.message_id,
            plain_text: val.plain_text,
            html_content: val.html_content,
            content: val.content,
            attachments: val.attachments.into_iter().map(Into::into).collect(),
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmailAttachment> for EmailAttachment {
    fn from(val: kreuzberg::EmailAttachment) -> Self {
        Self {
            name: val.name,
            filename: val.filename,
            mime_type: val.mime_type,
            size: val.size.map(|v| v as i64),
            is_image: val.is_image,
            data: val.data.map(|v| v.to_vec().into()),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<OcrExtractionResult> for kreuzberg::OcrExtractionResult {
    fn from(val: OcrExtractionResult) -> Self {
        Self {
            content: val.content,
            mime_type: val.mime_type,
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v)).collect(),
            tables: val.tables.into_iter().map(Into::into).collect(),
            ocr_elements: val.ocr_elements.map(|v| v.into_iter().map(Into::into).collect()),
            internal_document: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrExtractionResult> for OcrExtractionResult {
    fn from(val: kreuzberg::OcrExtractionResult) -> Self {
        Self {
            content: val.content,
            mime_type: val.mime_type,
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v)).collect(),
            tables: val.tables.into_iter().map(Into::into).collect(),
            ocr_elements: val.ocr_elements.map(|v| v.into_iter().map(Into::into).collect()),
            internal_document: val.internal_document.as_ref().map(|v| format!("{v:?}")),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrTable> for kreuzberg::OcrTable {
    fn from(val: OcrTable) -> Self {
        Self {
            cells: val.cells.into_iter().collect(),
            markdown: val.markdown,
            page_number: val.page_number,
            bounding_box: val.bounding_box.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrTable> for OcrTable {
    fn from(val: kreuzberg::OcrTable) -> Self {
        Self {
            cells: val.cells.into_iter().collect(),
            markdown: val.markdown,
            page_number: val.page_number,
            bounding_box: val.bounding_box.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrTableBoundingBox> for kreuzberg::OcrTableBoundingBox {
    fn from(val: OcrTableBoundingBox) -> Self {
        Self {
            left: val.left,
            top: val.top,
            right: val.right,
            bottom: val.bottom,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrTableBoundingBox> for OcrTableBoundingBox {
    fn from(val: kreuzberg::OcrTableBoundingBox) -> Self {
        Self {
            left: val.left,
            top: val.top,
            right: val.right,
            bottom: val.bottom,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ImagePreprocessingConfig> for kreuzberg::ImagePreprocessingConfig {
    fn from(val: ImagePreprocessingConfig) -> Self {
        Self {
            target_dpi: val.target_dpi,
            auto_rotate: val.auto_rotate,
            deskew: val.deskew,
            denoise: val.denoise,
            contrast_enhance: val.contrast_enhance,
            binarization_method: val.binarization_method,
            invert_colors: val.invert_colors,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ImagePreprocessingConfig> for ImagePreprocessingConfig {
    fn from(val: kreuzberg::ImagePreprocessingConfig) -> Self {
        Self {
            target_dpi: val.target_dpi,
            auto_rotate: val.auto_rotate,
            deskew: val.deskew,
            denoise: val.denoise,
            contrast_enhance: val.contrast_enhance,
            binarization_method: val.binarization_method,
            invert_colors: val.invert_colors,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TesseractConfig> for kreuzberg::TesseractConfig {
    fn from(val: TesseractConfig) -> Self {
        Self {
            language: val.language,
            psm: val.psm,
            output_format: val.output_format,
            oem: val.oem,
            min_confidence: val.min_confidence,
            preprocessing: val.preprocessing.map(Into::into),
            enable_table_detection: val.enable_table_detection,
            table_min_confidence: val.table_min_confidence,
            table_column_threshold: val.table_column_threshold,
            table_row_threshold_ratio: val.table_row_threshold_ratio,
            use_cache: val.use_cache,
            classify_use_pre_adapted_templates: val.classify_use_pre_adapted_templates,
            language_model_ngram_on: val.language_model_ngram_on,
            tessedit_dont_blkrej_good_wds: val.tessedit_dont_blkrej_good_wds,
            tessedit_dont_rowrej_good_wds: val.tessedit_dont_rowrej_good_wds,
            tessedit_enable_dict_correction: val.tessedit_enable_dict_correction,
            tessedit_char_whitelist: val.tessedit_char_whitelist,
            tessedit_char_blacklist: val.tessedit_char_blacklist,
            tessedit_use_primary_params_model: val.tessedit_use_primary_params_model,
            textord_space_size_is_variable: val.textord_space_size_is_variable,
            thresholding_method: val.thresholding_method,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TesseractConfig> for TesseractConfig {
    fn from(val: kreuzberg::TesseractConfig) -> Self {
        Self {
            language: val.language,
            psm: val.psm,
            output_format: val.output_format,
            oem: val.oem,
            min_confidence: val.min_confidence,
            preprocessing: val.preprocessing.map(Into::into),
            enable_table_detection: val.enable_table_detection,
            table_min_confidence: val.table_min_confidence,
            table_column_threshold: val.table_column_threshold,
            table_row_threshold_ratio: val.table_row_threshold_ratio,
            use_cache: val.use_cache,
            classify_use_pre_adapted_templates: val.classify_use_pre_adapted_templates,
            language_model_ngram_on: val.language_model_ngram_on,
            tessedit_dont_blkrej_good_wds: val.tessedit_dont_blkrej_good_wds,
            tessedit_dont_rowrej_good_wds: val.tessedit_dont_rowrej_good_wds,
            tessedit_enable_dict_correction: val.tessedit_enable_dict_correction,
            tessedit_char_whitelist: val.tessedit_char_whitelist,
            tessedit_char_blacklist: val.tessedit_char_blacklist,
            tessedit_use_primary_params_model: val.tessedit_use_primary_params_model,
            textord_space_size_is_variable: val.textord_space_size_is_variable,
            thresholding_method: val.thresholding_method,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ImagePreprocessingMetadata> for kreuzberg::ImagePreprocessingMetadata {
    fn from(val: ImagePreprocessingMetadata) -> Self {
        Self {
            original_dimensions: Default::default(),
            original_dpi: Default::default(),
            target_dpi: val.target_dpi,
            scale_factor: val.scale_factor,
            auto_adjusted: val.auto_adjusted,
            final_dpi: val.final_dpi,
            new_dimensions: Default::default(),
            resample_method: val.resample_method,
            dimension_clamped: val.dimension_clamped,
            calculated_dpi: val.calculated_dpi,
            skipped_resize: val.skipped_resize,
            resize_error: val.resize_error,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ImagePreprocessingMetadata> for ImagePreprocessingMetadata {
    fn from(val: kreuzberg::ImagePreprocessingMetadata) -> Self {
        Self {
            original_dimensions: vec![val.original_dimensions.0 as _, val.original_dimensions.1 as _],
            original_dpi: vec![val.original_dpi.0 as _, val.original_dpi.1 as _],
            target_dpi: val.target_dpi,
            scale_factor: val.scale_factor,
            auto_adjusted: val.auto_adjusted,
            final_dpi: val.final_dpi,
            new_dimensions: val.new_dimensions.map(|t| {
                let arr: Vec<_> = [t.0, t.1].into_iter().map(|v| v as _).collect();
                arr
            }),
            resample_method: val.resample_method,
            dimension_clamped: val.dimension_clamped,
            calculated_dpi: val.calculated_dpi,
            skipped_resize: val.skipped_resize,
            resize_error: val.resize_error,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<Metadata> for kreuzberg::Metadata {
    fn from(val: Metadata) -> Self {
        Self {
            title: val.title,
            subject: val.subject,
            authors: val.authors.map(|v| v.into_iter().collect()),
            keywords: val.keywords.map(|v| v.into_iter().collect()),
            language: val.language,
            created_at: val.created_at,
            modified_at: val.modified_at,
            created_by: val.created_by,
            modified_by: val.modified_by,
            pages: val.pages.map(Into::into),
            format: val.format.map(Into::into),
            image_preprocessing: val.image_preprocessing.map(Into::into),
            json_schema: val.json_schema,
            error: val.error.map(Into::into),
            extraction_duration_ms: val.extraction_duration_ms.map(|v| v as u64),
            category: val.category,
            tags: val.tags.map(|v| v.into_iter().collect()),
            document_version: val.document_version,
            abstract_text: val.abstract_text,
            output_format: val.output_format,
            ocr_used: val.ocr_used,
            additional: val.additional.into_iter().map(|(k, v)| (k.into(), v)).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::Metadata> for Metadata {
    fn from(val: kreuzberg::Metadata) -> Self {
        Self {
            title: val.title,
            subject: val.subject,
            authors: val.authors.map(|v| v.into_iter().collect()),
            keywords: val.keywords.map(|v| v.into_iter().collect()),
            language: val.language,
            created_at: val.created_at,
            modified_at: val.modified_at,
            created_by: val.created_by,
            modified_by: val.modified_by,
            pages: val.pages.map(Into::into),
            format: val.format.map(Into::into),
            image_preprocessing: val.image_preprocessing.map(Into::into),
            json_schema: val.json_schema,
            error: val.error.map(Into::into),
            extraction_duration_ms: val.extraction_duration_ms.map(|v| v as i64),
            category: val.category,
            tags: val.tags.map(|v| v.into_iter().collect()),
            document_version: val.document_version,
            abstract_text: val.abstract_text,
            output_format: val.output_format,
            ocr_used: val.ocr_used,
            additional: val.additional.into_iter().map(|(k, v)| (k.into(), v)).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ExcelMetadata> for kreuzberg::ExcelMetadata {
    fn from(val: ExcelMetadata) -> Self {
        Self {
            sheet_count: val.sheet_count,
            sheet_names: val.sheet_names.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExcelMetadata> for ExcelMetadata {
    fn from(val: kreuzberg::ExcelMetadata) -> Self {
        Self {
            sheet_count: val.sheet_count,
            sheet_names: val.sheet_names.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EmailMetadata> for kreuzberg::EmailMetadata {
    fn from(val: EmailMetadata) -> Self {
        Self {
            from_email: val.from_email,
            from_name: val.from_name,
            to_emails: val.to_emails.into_iter().collect(),
            cc_emails: val.cc_emails.into_iter().collect(),
            bcc_emails: val.bcc_emails.into_iter().collect(),
            message_id: val.message_id,
            attachments: val.attachments.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmailMetadata> for EmailMetadata {
    fn from(val: kreuzberg::EmailMetadata) -> Self {
        Self {
            from_email: val.from_email,
            from_name: val.from_name,
            to_emails: val.to_emails.into_iter().collect(),
            cc_emails: val.cc_emails.into_iter().collect(),
            bcc_emails: val.bcc_emails.into_iter().collect(),
            message_id: val.message_id,
            attachments: val.attachments.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ArchiveMetadata> for kreuzberg::ArchiveMetadata {
    fn from(val: ArchiveMetadata) -> Self {
        Self {
            format: val.format.into(),
            file_count: val.file_count,
            file_list: val.file_list.into_iter().collect(),
            total_size: val.total_size as u64,
            compressed_size: val.compressed_size.map(|v| v as u64),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ArchiveMetadata> for ArchiveMetadata {
    fn from(val: kreuzberg::ArchiveMetadata) -> Self {
        Self {
            format: val.format.to_string(),
            file_count: val.file_count,
            file_list: val.file_list.into_iter().collect(),
            total_size: val.total_size as i64,
            compressed_size: val.compressed_size.map(|v| v as i64),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ImageMetadata> for kreuzberg::ImageMetadata {
    fn from(val: ImageMetadata) -> Self {
        Self {
            width: val.width,
            height: val.height,
            format: val.format,
            exif: val.exif.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ImageMetadata> for ImageMetadata {
    fn from(val: kreuzberg::ImageMetadata) -> Self {
        Self {
            width: val.width,
            height: val.height,
            format: val.format,
            exif: val.exif.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<XmlMetadata> for kreuzberg::XmlMetadata {
    fn from(val: XmlMetadata) -> Self {
        Self {
            element_count: val.element_count,
            unique_elements: val.unique_elements.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::XmlMetadata> for XmlMetadata {
    fn from(val: kreuzberg::XmlMetadata) -> Self {
        Self {
            element_count: val.element_count,
            unique_elements: val.unique_elements.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TextMetadata> for kreuzberg::TextMetadata {
    fn from(val: TextMetadata) -> Self {
        Self {
            line_count: val.line_count,
            word_count: val.word_count,
            character_count: val.character_count,
            headers: val.headers.map(|v| v.into_iter().collect()),
            links: Default::default(),
            code_blocks: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TextMetadata> for TextMetadata {
    fn from(val: kreuzberg::TextMetadata) -> Self {
        Self {
            line_count: val.line_count,
            word_count: val.word_count,
            character_count: val.character_count,
            headers: val.headers.map(|v| v.into_iter().collect()),
            links: val.links.as_ref().map(|v| {
                v.iter()
                    .map(|(a, b)| vec![a.to_string(), b.to_string()])
                    .collect::<Vec<Vec<String>>>()
            }),
            code_blocks: val.code_blocks.as_ref().map(|v| {
                v.iter()
                    .map(|(a, b)| vec![a.to_string(), b.to_string()])
                    .collect::<Vec<Vec<String>>>()
            }),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<HeaderMetadata> for kreuzberg::HeaderMetadata {
    fn from(val: HeaderMetadata) -> Self {
        Self {
            level: val.level,
            text: val.text,
            id: val.id,
            depth: val.depth,
            html_offset: val.html_offset,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HeaderMetadata> for HeaderMetadata {
    fn from(val: kreuzberg::HeaderMetadata) -> Self {
        Self {
            level: val.level,
            text: val.text,
            id: val.id,
            depth: val.depth,
            html_offset: val.html_offset,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<LinkMetadata> for kreuzberg::LinkMetadata {
    fn from(val: LinkMetadata) -> Self {
        Self {
            href: val.href,
            text: val.text,
            title: val.title,
            link_type: match val.link_type.as_str() {
                "anchor" => kreuzberg::LinkType::Anchor,
                "internal" => kreuzberg::LinkType::Internal,
                "external" => kreuzberg::LinkType::External,
                "email" => kreuzberg::LinkType::Email,
                "phone" => kreuzberg::LinkType::Phone,
                "other" => kreuzberg::LinkType::Other,
                _ => kreuzberg::LinkType::Anchor,
            },
            rel: val.rel.into_iter().collect(),
            attributes: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LinkMetadata> for LinkMetadata {
    fn from(val: kreuzberg::LinkMetadata) -> Self {
        Self {
            href: val.href,
            text: val.text,
            title: val.title,
            link_type: serde_json::to_value(val.link_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            rel: val.rel.into_iter().collect(),
            attributes: val
                .attributes
                .iter()
                .map(|(a, b)| vec![a.to_string(), b.to_string()])
                .collect::<Vec<Vec<String>>>(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ImageMetadataType> for kreuzberg::ImageMetadataType {
    fn from(val: ImageMetadataType) -> Self {
        Self {
            src: val.src,
            alt: val.alt,
            title: val.title,
            dimensions: Default::default(),
            image_type: match val.image_type.as_str() {
                "data-uri" => kreuzberg::ImageType::DataUri,
                "inline-svg" => kreuzberg::ImageType::InlineSvg,
                "external" => kreuzberg::ImageType::External,
                "relative" => kreuzberg::ImageType::Relative,
                _ => kreuzberg::ImageType::DataUri,
            },
            attributes: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ImageMetadataType> for ImageMetadataType {
    fn from(val: kreuzberg::ImageMetadataType) -> Self {
        Self {
            src: val.src,
            alt: val.alt,
            title: val.title,
            dimensions: val.dimensions.map(|t| {
                let arr: Vec<_> = [t.0, t.1].into_iter().map(|v| v as _).collect();
                arr
            }),
            image_type: serde_json::to_value(val.image_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            attributes: val
                .attributes
                .iter()
                .map(|(a, b)| vec![a.to_string(), b.to_string()])
                .collect::<Vec<Vec<String>>>(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<StructuredData> for kreuzberg::StructuredData {
    fn from(val: StructuredData) -> Self {
        Self {
            data_type: match val.data_type.as_str() {
                "json-ld" => kreuzberg::StructuredDataType::JsonLd,
                "microdata" => kreuzberg::StructuredDataType::Microdata,
                "rdfa" => kreuzberg::StructuredDataType::RDFa,
                _ => kreuzberg::StructuredDataType::JsonLd,
            },
            raw_json: val.raw_json,
            schema_type: val.schema_type,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::StructuredData> for StructuredData {
    fn from(val: kreuzberg::StructuredData) -> Self {
        Self {
            data_type: serde_json::to_value(val.data_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            raw_json: val.raw_json,
            schema_type: val.schema_type,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<HtmlMetadata> for kreuzberg::HtmlMetadata {
    fn from(val: HtmlMetadata) -> Self {
        Self {
            title: val.title,
            description: val.description,
            keywords: val.keywords.into_iter().collect(),
            author: val.author,
            canonical_url: val.canonical_url,
            base_href: val.base_href,
            language: val.language,
            text_direction: val.text_direction.as_deref().map(|s| match s {
                "ltr" => kreuzberg::TextDirection::LeftToRight,
                "rtl" => kreuzberg::TextDirection::RightToLeft,
                "auto" => kreuzberg::TextDirection::Auto,
                _ => kreuzberg::TextDirection::LeftToRight,
            }),
            open_graph: val.open_graph.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            twitter_card: val
                .twitter_card
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
            meta_tags: val.meta_tags.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            headers: val.headers.into_iter().map(Into::into).collect(),
            links: val.links.into_iter().map(Into::into).collect(),
            images: val.images.into_iter().map(Into::into).collect(),
            structured_data: val.structured_data.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HtmlMetadata> for HtmlMetadata {
    fn from(val: kreuzberg::HtmlMetadata) -> Self {
        Self {
            title: val.title,
            description: val.description,
            keywords: val.keywords.into_iter().collect(),
            author: val.author,
            canonical_url: val.canonical_url,
            base_href: val.base_href,
            language: val.language,
            text_direction: val.text_direction.as_ref().map(|v| {
                serde_json::to_value(v)
                    .ok()
                    .and_then(|s| s.as_str().map(String::from))
                    .unwrap_or_default()
            }),
            open_graph: val.open_graph.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            twitter_card: val
                .twitter_card
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
            meta_tags: val.meta_tags.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            headers: val.headers.into_iter().map(Into::into).collect(),
            links: val.links.into_iter().map(Into::into).collect(),
            images: val.images.into_iter().map(Into::into).collect(),
            structured_data: val.structured_data.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrMetadata> for kreuzberg::OcrMetadata {
    fn from(val: OcrMetadata) -> Self {
        Self {
            language: val.language,
            psm: val.psm,
            output_format: val.output_format,
            table_count: val.table_count,
            table_rows: val.table_rows,
            table_cols: val.table_cols,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrMetadata> for OcrMetadata {
    fn from(val: kreuzberg::OcrMetadata) -> Self {
        Self {
            language: val.language,
            psm: val.psm,
            output_format: val.output_format,
            table_count: val.table_count,
            table_rows: val.table_rows,
            table_cols: val.table_cols,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ErrorMetadata> for kreuzberg::ErrorMetadata {
    fn from(val: ErrorMetadata) -> Self {
        Self {
            error_type: val.error_type,
            message: val.message,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ErrorMetadata> for ErrorMetadata {
    fn from(val: kreuzberg::ErrorMetadata) -> Self {
        Self {
            error_type: val.error_type,
            message: val.message,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PptxMetadata> for kreuzberg::PptxMetadata {
    fn from(val: PptxMetadata) -> Self {
        Self {
            slide_count: val.slide_count,
            slide_names: val.slide_names.into_iter().collect(),
            image_count: val.image_count,
            table_count: val.table_count,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PptxMetadata> for PptxMetadata {
    fn from(val: kreuzberg::PptxMetadata) -> Self {
        Self {
            slide_count: val.slide_count,
            slide_names: val.slide_names.into_iter().collect(),
            image_count: val.image_count,
            table_count: val.table_count,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DocxMetadata> for kreuzberg::DocxMetadata {
    fn from(val: DocxMetadata) -> Self {
        Self {
            core_properties: val.core_properties.map(Into::into),
            app_properties: val.app_properties.map(Into::into),
            custom_properties: val
                .custom_properties
                .map(|m| m.into_iter().map(|(k, v)| (k.into(), v)).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DocxMetadata> for DocxMetadata {
    fn from(val: kreuzberg::DocxMetadata) -> Self {
        Self {
            core_properties: val.core_properties.map(Into::into),
            app_properties: val.app_properties.map(Into::into),
            custom_properties: val
                .custom_properties
                .map(|m| m.into_iter().map(|(k, v)| (k.into(), v)).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<CsvMetadata> for kreuzberg::CsvMetadata {
    fn from(val: CsvMetadata) -> Self {
        Self {
            row_count: val.row_count,
            column_count: val.column_count,
            delimiter: val.delimiter,
            has_header: val.has_header,
            column_types: val.column_types.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::CsvMetadata> for CsvMetadata {
    fn from(val: kreuzberg::CsvMetadata) -> Self {
        Self {
            row_count: val.row_count,
            column_count: val.column_count,
            delimiter: val.delimiter,
            has_header: val.has_header,
            column_types: val.column_types.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<BibtexMetadata> for kreuzberg::BibtexMetadata {
    fn from(val: BibtexMetadata) -> Self {
        Self {
            entry_count: val.entry_count as usize,
            citation_keys: val.citation_keys.into_iter().collect(),
            authors: val.authors.into_iter().collect(),
            year_range: val.year_range.map(Into::into),
            entry_types: val
                .entry_types
                .map(|m| m.into_iter().map(|(k, v)| (k, v as usize)).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::BibtexMetadata> for BibtexMetadata {
    fn from(val: kreuzberg::BibtexMetadata) -> Self {
        Self {
            entry_count: val.entry_count as i64,
            citation_keys: val.citation_keys.into_iter().collect(),
            authors: val.authors.into_iter().collect(),
            year_range: val.year_range.map(Into::into),
            entry_types: val
                .entry_types
                .as_ref()
                .map(|m| m.iter().map(|(k, v)| (k.clone(), *v as i64)).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<CitationMetadata> for kreuzberg::CitationMetadata {
    fn from(val: CitationMetadata) -> Self {
        Self {
            citation_count: val.citation_count as usize,
            format: val.format,
            authors: val.authors.into_iter().collect(),
            year_range: val.year_range.map(Into::into),
            dois: val.dois.into_iter().collect(),
            keywords: val.keywords.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::CitationMetadata> for CitationMetadata {
    fn from(val: kreuzberg::CitationMetadata) -> Self {
        Self {
            citation_count: val.citation_count as i64,
            format: val.format,
            authors: val.authors.into_iter().collect(),
            year_range: val.year_range.map(Into::into),
            dois: val.dois.into_iter().collect(),
            keywords: val.keywords.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<YearRange> for kreuzberg::YearRange {
    fn from(val: YearRange) -> Self {
        Self {
            min: val.min,
            max: val.max,
            years: val.years.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::YearRange> for YearRange {
    fn from(val: kreuzberg::YearRange) -> Self {
        Self {
            min: val.min,
            max: val.max,
            years: val.years.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<FictionBookMetadata> for kreuzberg::FictionBookMetadata {
    fn from(val: FictionBookMetadata) -> Self {
        Self {
            genres: val.genres.into_iter().collect(),
            sequences: val.sequences.into_iter().collect(),
            annotation: val.annotation,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::FictionBookMetadata> for FictionBookMetadata {
    fn from(val: kreuzberg::FictionBookMetadata) -> Self {
        Self {
            genres: val.genres.into_iter().collect(),
            sequences: val.sequences.into_iter().collect(),
            annotation: val.annotation,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DbfMetadata> for kreuzberg::DbfMetadata {
    fn from(val: DbfMetadata) -> Self {
        Self {
            record_count: val.record_count as usize,
            field_count: val.field_count as usize,
            fields: val.fields.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DbfMetadata> for DbfMetadata {
    fn from(val: kreuzberg::DbfMetadata) -> Self {
        Self {
            record_count: val.record_count as i64,
            field_count: val.field_count as i64,
            fields: val.fields.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DbfFieldInfo> for kreuzberg::DbfFieldInfo {
    fn from(val: DbfFieldInfo) -> Self {
        Self {
            name: val.name,
            field_type: val.field_type,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DbfFieldInfo> for DbfFieldInfo {
    fn from(val: kreuzberg::DbfFieldInfo) -> Self {
        Self {
            name: val.name,
            field_type: val.field_type,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<JatsMetadata> for kreuzberg::JatsMetadata {
    fn from(val: JatsMetadata) -> Self {
        Self {
            copyright: val.copyright,
            license: val.license,
            history_dates: val
                .history_dates
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
            contributor_roles: val.contributor_roles.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::JatsMetadata> for JatsMetadata {
    fn from(val: kreuzberg::JatsMetadata) -> Self {
        Self {
            copyright: val.copyright,
            license: val.license,
            history_dates: val
                .history_dates
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
            contributor_roles: val.contributor_roles.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ContributorRole> for kreuzberg::ContributorRole {
    fn from(val: ContributorRole) -> Self {
        Self {
            name: val.name,
            role: val.role,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ContributorRole> for ContributorRole {
    fn from(val: kreuzberg::ContributorRole) -> Self {
        Self {
            name: val.name,
            role: val.role,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EpubMetadata> for kreuzberg::EpubMetadata {
    fn from(val: EpubMetadata) -> Self {
        Self {
            coverage: val.coverage,
            dc_format: val.dc_format,
            relation: val.relation,
            source: val.source,
            dc_type: val.dc_type,
            cover_image: val.cover_image,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EpubMetadata> for EpubMetadata {
    fn from(val: kreuzberg::EpubMetadata) -> Self {
        Self {
            coverage: val.coverage,
            dc_format: val.dc_format,
            relation: val.relation,
            source: val.source,
            dc_type: val.dc_type,
            cover_image: val.cover_image,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PstMetadata> for kreuzberg::PstMetadata {
    fn from(val: PstMetadata) -> Self {
        Self {
            message_count: val.message_count as usize,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PstMetadata> for PstMetadata {
    fn from(val: kreuzberg::PstMetadata) -> Self {
        Self {
            message_count: val.message_count as i64,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrConfidence> for kreuzberg::OcrConfidence {
    fn from(val: OcrConfidence) -> Self {
        Self {
            detection: val.detection,
            recognition: val.recognition,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrConfidence> for OcrConfidence {
    fn from(val: kreuzberg::OcrConfidence) -> Self {
        Self {
            detection: val.detection,
            recognition: val.recognition,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OcrRotation> for kreuzberg::OcrRotation {
    fn from(val: OcrRotation) -> Self {
        Self {
            angle_degrees: val.angle_degrees,
            confidence: val.confidence,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrRotation> for OcrRotation {
    fn from(val: kreuzberg::OcrRotation) -> Self {
        Self {
            angle_degrees: val.angle_degrees,
            confidence: val.confidence,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<OcrElement> for kreuzberg::OcrElement {
    fn from(val: OcrElement) -> Self {
        Self {
            text: val.text,
            geometry: val.geometry.into(),
            confidence: val.confidence.into(),
            level: match val.level.as_str() {
                "word" => kreuzberg::OcrElementLevel::Word,
                "line" => kreuzberg::OcrElementLevel::Line,
                "block" => kreuzberg::OcrElementLevel::Block,
                "page" => kreuzberg::OcrElementLevel::Page,
                _ => Default::default(),
            },
            rotation: val.rotation.map(Into::into),
            page_number: val.page_number,
            parent_id: val.parent_id,
            backend_metadata: val.backend_metadata.into_iter().map(|(k, v)| (k.into(), v)).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrElement> for OcrElement {
    fn from(val: kreuzberg::OcrElement) -> Self {
        Self {
            text: val.text,
            geometry: val.geometry.into(),
            confidence: val.confidence.into(),
            level: serde_json::to_value(val.level)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            rotation: val.rotation.map(Into::into),
            page_number: val.page_number,
            parent_id: val.parent_id,
            backend_metadata: val.backend_metadata.into_iter().map(|(k, v)| (k.into(), v)).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<OcrElementConfig> for kreuzberg::OcrElementConfig {
    fn from(val: OcrElementConfig) -> Self {
        Self {
            include_elements: val.include_elements,
            min_level: match val.min_level.as_str() {
                "word" => kreuzberg::OcrElementLevel::Word,
                "line" => kreuzberg::OcrElementLevel::Line,
                "block" => kreuzberg::OcrElementLevel::Block,
                "page" => kreuzberg::OcrElementLevel::Page,
                _ => Default::default(),
            },
            min_confidence: val.min_confidence,
            build_hierarchy: val.build_hierarchy,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OcrElementConfig> for OcrElementConfig {
    fn from(val: kreuzberg::OcrElementConfig) -> Self {
        Self {
            include_elements: val.include_elements,
            min_level: serde_json::to_value(val.min_level)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            min_confidence: val.min_confidence,
            build_hierarchy: val.build_hierarchy,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<PageStructure> for kreuzberg::PageStructure {
    fn from(val: PageStructure) -> Self {
        Self {
            total_count: val.total_count,
            unit_type: match val.unit_type.as_str() {
                "page" => kreuzberg::PageUnitType::Page,
                "slide" => kreuzberg::PageUnitType::Slide,
                "sheet" => kreuzberg::PageUnitType::Sheet,
                _ => kreuzberg::PageUnitType::Page,
            },
            boundaries: val.boundaries.map(|v| v.into_iter().map(Into::into).collect()),
            pages: val.pages.map(|v| v.into_iter().map(Into::into).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PageStructure> for PageStructure {
    fn from(val: kreuzberg::PageStructure) -> Self {
        Self {
            total_count: val.total_count,
            unit_type: serde_json::to_value(val.unit_type)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            boundaries: val.boundaries.map(|v| v.into_iter().map(Into::into).collect()),
            pages: val.pages.map(|v| v.into_iter().map(Into::into).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PageBoundary> for kreuzberg::PageBoundary {
    fn from(val: PageBoundary) -> Self {
        Self {
            byte_start: val.byte_start as usize,
            byte_end: val.byte_end as usize,
            page_number: val.page_number,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PageBoundary> for PageBoundary {
    fn from(val: kreuzberg::PageBoundary) -> Self {
        Self {
            byte_start: val.byte_start as i64,
            byte_end: val.byte_end as i64,
            page_number: val.page_number,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PageInfo> for kreuzberg::PageInfo {
    fn from(val: PageInfo) -> Self {
        Self {
            number: val.number,
            title: val.title,
            dimensions: Default::default(),
            image_count: val.image_count,
            table_count: val.table_count,
            hidden: val.hidden,
            is_blank: val.is_blank,
            has_vector_graphics: val.has_vector_graphics,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PageInfo> for PageInfo {
    fn from(val: kreuzberg::PageInfo) -> Self {
        Self {
            number: val.number,
            title: val.title,
            dimensions: val.dimensions.map(|t| {
                let arr: Vec<_> = [t.0, t.1].into_iter().map(|v| v as _).collect();
                arr
            }),
            image_count: val.image_count,
            table_count: val.table_count,
            hidden: val.hidden,
            is_blank: val.is_blank,
            has_vector_graphics: val.has_vector_graphics,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PageContent> for kreuzberg::PageContent {
    fn from(val: PageContent) -> Self {
        Self {
            page_number: val.page_number,
            content: val.content,
            tables: val.tables.into_iter().map(|v| std::sync::Arc::new(v.into())).collect(),
            image_indices: val.image_indices.into_iter().collect(),
            hierarchy: val.hierarchy.map(Into::into),
            is_blank: val.is_blank,
            layout_regions: val.layout_regions.map(|v| v.into_iter().map(Into::into).collect()),
            speaker_notes: val.speaker_notes,
            section_name: val.section_name,
            sheet_name: val.sheet_name,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PageContent> for PageContent {
    fn from(val: kreuzberg::PageContent) -> Self {
        Self {
            page_number: val.page_number,
            content: val.content,
            tables: val.tables.into_iter().map(|v| (*v).clone().into()).collect(),
            image_indices: val.image_indices.into_iter().collect(),
            hierarchy: val.hierarchy.map(Into::into),
            is_blank: val.is_blank,
            layout_regions: val.layout_regions.map(|v| v.into_iter().map(Into::into).collect()),
            speaker_notes: val.speaker_notes,
            section_name: val.section_name,
            sheet_name: val.sheet_name,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<LayoutRegion> for kreuzberg::LayoutRegion {
    fn from(val: LayoutRegion) -> Self {
        Self {
            class_name: val.class_name,
            confidence: val.confidence,
            bounding_box: val.bounding_box.into(),
            area_fraction: val.area_fraction,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LayoutRegion> for LayoutRegion {
    fn from(val: kreuzberg::LayoutRegion) -> Self {
        Self {
            class_name: val.class_name,
            confidence: val.confidence,
            bounding_box: val.bounding_box.into(),
            area_fraction: val.area_fraction,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PageHierarchy> for kreuzberg::PageHierarchy {
    fn from(val: PageHierarchy) -> Self {
        Self {
            block_count: val.block_count,
            blocks: val.blocks.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PageHierarchy> for PageHierarchy {
    fn from(val: kreuzberg::PageHierarchy) -> Self {
        Self {
            block_count: val.block_count,
            blocks: val.blocks.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<HierarchicalBlock> for kreuzberg::HierarchicalBlock {
    fn from(val: HierarchicalBlock) -> Self {
        Self {
            text: val.text,
            font_size: val.font_size,
            level: val.level,
            bbox: Default::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::HierarchicalBlock> for HierarchicalBlock {
    fn from(val: kreuzberg::HierarchicalBlock) -> Self {
        Self {
            text: val.text,
            font_size: val.font_size,
            level: val.level,
            bbox: val.bbox.map(|t| {
                let arr: Vec<_> = [t.0, t.1].into_iter().map(|v| v as _).collect();
                arr
            }),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<CellChange> for kreuzberg::CellChange {
    fn from(val: CellChange) -> Self {
        Self {
            row: val.row as usize,
            col: val.col as usize,
            from: val.from,
            to: val.to,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::CellChange> for CellChange {
    fn from(val: kreuzberg::CellChange) -> Self {
        Self {
            row: val.row as i64,
            col: val.col as i64,
            from: val.from,
            to: val.to,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<DocumentRevision> for kreuzberg::DocumentRevision {
    fn from(val: DocumentRevision) -> Self {
        Self {
            revision_id: val.revision_id,
            author: val.author,
            timestamp: val.timestamp,
            kind: match val.kind.as_str() {
                "insertion" => kreuzberg::RevisionKind::Insertion,
                "deletion" => kreuzberg::RevisionKind::Deletion,
                "format_change" => kreuzberg::RevisionKind::FormatChange,
                "comment" => kreuzberg::RevisionKind::Comment,
                _ => kreuzberg::RevisionKind::Insertion,
            },
            anchor: val.anchor.map(Into::into),
            delta: val.delta.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DocumentRevision> for DocumentRevision {
    fn from(val: kreuzberg::DocumentRevision) -> Self {
        Self {
            revision_id: val.revision_id,
            author: val.author,
            timestamp: val.timestamp,
            kind: serde_json::to_value(val.kind)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            anchor: val.anchor.map(Into::into),
            delta: val.delta.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<RevisionDelta> for kreuzberg::RevisionDelta {
    fn from(val: RevisionDelta) -> Self {
        Self {
            content: val.content.into_iter().map(Into::into).collect(),
            table_changes: val.table_changes.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::RevisionDelta> for RevisionDelta {
    fn from(val: kreuzberg::RevisionDelta) -> Self {
        Self {
            content: val.content.into_iter().map(Into::into).collect(),
            table_changes: val.table_changes.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<Table> for kreuzberg::Table {
    fn from(val: Table) -> Self {
        Self {
            cells: val.cells.into_iter().collect(),
            markdown: val.markdown,
            page_number: val.page_number,
            bounding_box: val.bounding_box.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::Table> for Table {
    fn from(val: kreuzberg::Table) -> Self {
        Self {
            cells: val.cells.into_iter().collect(),
            markdown: val.markdown,
            page_number: val.page_number,
            bounding_box: val.bounding_box.map(Into::into),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TableCell> for TableCell {
    fn from(val: kreuzberg::TableCell) -> Self {
        Self {
            content: val.content,
            row_span: val.row_span,
            col_span: val.col_span,
            is_header: val.is_header,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ExtractedUri> for kreuzberg::ExtractedUri {
    fn from(val: ExtractedUri) -> Self {
        Self {
            url: val.url,
            label: val.label,
            page: val.page,
            kind: match val.kind.as_str() {
                "hyperlink" => kreuzberg::UriKind::Hyperlink,
                "image" => kreuzberg::UriKind::Image,
                "anchor" => kreuzberg::UriKind::Anchor,
                "citation" => kreuzberg::UriKind::Citation,
                "reference" => kreuzberg::UriKind::Reference,
                "email" => kreuzberg::UriKind::Email,
                _ => kreuzberg::UriKind::Hyperlink,
            },
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExtractedUri> for ExtractedUri {
    fn from(val: kreuzberg::ExtractedUri) -> Self {
        Self {
            url: val.url,
            label: val.label,
            page: val.page,
            kind: serde_json::to_value(val.kind)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::api::DetectResponse> for DetectResponse {
    fn from(val: kreuzberg::api::DetectResponse) -> Self {
        Self {
            mime_type: val.mime_type,
            filename: val.filename,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DiffOptions> for kreuzberg::DiffOptions {
    fn from(val: DiffOptions) -> Self {
        Self {
            include_metadata: val.include_metadata,
            include_embedded: val.include_embedded,
            max_content_chars: val.max_content_chars.map(|v| v as usize),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DiffOptions> for DiffOptions {
    fn from(val: kreuzberg::DiffOptions) -> Self {
        Self {
            include_metadata: val.include_metadata,
            include_embedded: val.include_embedded,
            max_content_chars: val.max_content_chars.map(|v| v as i64),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ExtractionDiff> for kreuzberg::ExtractionDiff {
    fn from(val: ExtractionDiff) -> Self {
        Self {
            content_diff: val.content_diff.into_iter().map(Into::into).collect(),
            tables_added: val.tables_added.into_iter().map(Into::into).collect(),
            tables_removed: val.tables_removed.into_iter().map(Into::into).collect(),
            tables_changed: val.tables_changed.into_iter().map(Into::into).collect(),
            metadata_changed: val.metadata_changed,
            embedded_changes: val.embedded_changes.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ExtractionDiff> for ExtractionDiff {
    fn from(val: kreuzberg::ExtractionDiff) -> Self {
        Self {
            content_diff: val.content_diff.into_iter().map(Into::into).collect(),
            tables_added: val.tables_added.into_iter().map(Into::into).collect(),
            tables_removed: val.tables_removed.into_iter().map(Into::into).collect(),
            tables_changed: val.tables_changed.into_iter().map(Into::into).collect(),
            metadata_changed: val.metadata_changed,
            embedded_changes: val.embedded_changes.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DiffHunk> for kreuzberg::DiffHunk {
    fn from(val: DiffHunk) -> Self {
        Self {
            from_line: val.from_line as usize,
            from_count: val.from_count as usize,
            to_line: val.to_line as usize,
            to_count: val.to_count as usize,
            lines: val.lines.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DiffHunk> for DiffHunk {
    fn from(val: kreuzberg::DiffHunk) -> Self {
        Self {
            from_line: val.from_line as i64,
            from_count: val.from_count as i64,
            to_line: val.to_line as i64,
            to_count: val.to_count as i64,
            lines: val.lines.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TableDiff> for kreuzberg::TableDiff {
    fn from(val: TableDiff) -> Self {
        Self {
            from_index: val.from_index as usize,
            to_index: val.to_index as usize,
            cell_changes: val.cell_changes.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::TableDiff> for TableDiff {
    fn from(val: kreuzberg::TableDiff) -> Self {
        Self {
            from_index: val.from_index as i64,
            to_index: val.to_index as i64,
            cell_changes: val.cell_changes.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<EmbeddedChanges> for kreuzberg::EmbeddedChanges {
    fn from(val: EmbeddedChanges) -> Self {
        Self {
            added: val.added.into_iter().map(Into::into).collect(),
            removed: val.removed.into_iter().map(Into::into).collect(),
            changed: val.changed.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmbeddedChanges> for EmbeddedChanges {
    fn from(val: kreuzberg::EmbeddedChanges) -> Self {
        Self {
            added: val.added.into_iter().map(Into::into).collect(),
            removed: val.removed.into_iter().map(Into::into).collect(),
            changed: val.changed.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<EmbeddedDiff> for kreuzberg::EmbeddedDiff {
    fn from(val: EmbeddedDiff) -> Self {
        Self {
            path: val.path,
            diff: Box::new(val.diff.into()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmbeddedDiff> for EmbeddedDiff {
    fn from(val: kreuzberg::EmbeddedDiff) -> Self {
        Self {
            path: val.path,
            diff: (*val.diff).into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EmbeddingPreset> for kreuzberg::EmbeddingPreset {
    fn from(val: EmbeddingPreset) -> Self {
        Self {
            name: val.name,
            chunk_size: val.chunk_size as usize,
            overlap: val.overlap as usize,
            model_repo: val.model_repo,
            pooling: val.pooling,
            model_file: val.model_file,
            dimensions: val.dimensions as usize,
            description: val.description,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::EmbeddingPreset> for EmbeddingPreset {
    fn from(val: kreuzberg::EmbeddingPreset) -> Self {
        Self {
            name: val.name,
            chunk_size: val.chunk_size as i64,
            overlap: val.overlap as i64,
            model_repo: val.model_repo,
            pooling: val.pooling,
            model_file: val.model_file,
            dimensions: val.dimensions as i64,
            description: val.description,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<YakeParams> for kreuzberg::YakeParams {
    fn from(val: YakeParams) -> Self {
        Self {
            window_size: val.window_size as usize,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::YakeParams> for YakeParams {
    fn from(val: kreuzberg::YakeParams) -> Self {
        Self {
            window_size: val.window_size as i64,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<RakeParams> for kreuzberg::RakeParams {
    fn from(val: RakeParams) -> Self {
        Self {
            min_word_length: val.min_word_length as usize,
            max_words_per_phrase: val.max_words_per_phrase as usize,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::RakeParams> for RakeParams {
    fn from(val: kreuzberg::RakeParams) -> Self {
        Self {
            min_word_length: val.min_word_length as i64,
            max_words_per_phrase: val.max_words_per_phrase as i64,
        }
    }
}

#[allow(clippy::needless_update)]
#[allow(clippy::useless_conversion)]
impl From<KeywordConfig> for kreuzberg::KeywordConfig {
    fn from(val: KeywordConfig) -> Self {
        Self {
            algorithm: match val.algorithm.as_str() {
                "yake" => kreuzberg::KeywordAlgorithm::Yake,
                "rake" => kreuzberg::KeywordAlgorithm::Rake,
                _ => kreuzberg::KeywordAlgorithm::Yake,
            },
            max_keywords: val.max_keywords as usize,
            min_score: val.min_score,
            ngram_range: Default::default(),
            language: val.language,
            ..Default::default()
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::KeywordConfig> for KeywordConfig {
    fn from(val: kreuzberg::KeywordConfig) -> Self {
        Self {
            algorithm: serde_json::to_value(val.algorithm)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            max_keywords: val.max_keywords as i64,
            min_score: val.min_score,
            ngram_range: vec![val.ngram_range.0 as _, val.ngram_range.1 as _],
            language: val.language,
            yake_params: val.yake_params.map(Into::into),
            rake_params: val.rake_params.map(Into::into),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<Keyword> for kreuzberg::Keyword {
    fn from(val: Keyword) -> Self {
        Self {
            text: val.text,
            score: val.score,
            algorithm: match val.algorithm.as_str() {
                "yake" => kreuzberg::KeywordAlgorithm::Yake,
                "rake" => kreuzberg::KeywordAlgorithm::Rake,
                _ => kreuzberg::KeywordAlgorithm::Yake,
            },
            positions: val.positions.map(|v| v.into_iter().map(|x| x as usize).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::Keyword> for Keyword {
    fn from(val: kreuzberg::Keyword) -> Self {
        Self {
            text: val.text,
            score: val.score,
            algorithm: serde_json::to_value(val.algorithm)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            positions: val.positions.as_ref().map(|v| v.iter().map(|&x| x as i64).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PaddleOcrConfig> for kreuzberg::PaddleOcrConfig {
    fn from(val: PaddleOcrConfig) -> Self {
        Self {
            language: val.language,
            cache_dir: val.cache_dir.map(Into::into),
            use_angle_cls: val.use_angle_cls,
            enable_table_detection: val.enable_table_detection,
            det_db_thresh: val.det_db_thresh,
            det_db_box_thresh: val.det_db_box_thresh,
            det_db_unclip_ratio: val.det_db_unclip_ratio,
            det_limit_side_len: val.det_limit_side_len,
            rec_batch_num: val.rec_batch_num,
            padding: val.padding,
            drop_score: val.drop_score,
            model_tier: val.model_tier,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::PaddleOcrConfig> for PaddleOcrConfig {
    fn from(val: kreuzberg::PaddleOcrConfig) -> Self {
        Self {
            language: val.language,
            cache_dir: val.cache_dir.map(|p| p.to_string_lossy().to_string()),
            use_angle_cls: val.use_angle_cls,
            enable_table_detection: val.enable_table_detection,
            det_db_thresh: val.det_db_thresh,
            det_db_box_thresh: val.det_db_box_thresh,
            det_db_unclip_ratio: val.det_db_unclip_ratio,
            det_limit_side_len: val.det_limit_side_len,
            rec_batch_num: val.rec_batch_num,
            padding: val.padding,
            drop_score: val.drop_score,
            model_tier: val.model_tier,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::ModelPaths> for ModelPaths {
    fn from(val: kreuzberg::ModelPaths) -> Self {
        Self {
            det_model: val.det_model.to_string_lossy().to_string(),
            cls_model: val.cls_model.to_string_lossy().to_string(),
            rec_model: val.rec_model.to_string_lossy().to_string(),
            dict_file: val.dict_file.to_string_lossy().to_string(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::OrientationResult> for OrientationResult {
    fn from(val: kreuzberg::OrientationResult) -> Self {
        Self {
            degrees: val.degrees,
            confidence: val.confidence,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::BBox> for BBox {
    fn from(val: kreuzberg::BBox) -> Self {
        Self {
            x1: val.x1,
            y1: val.y1,
            x2: val.x2,
            y2: val.y2,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::LayoutDetection> for LayoutDetection {
    fn from(val: kreuzberg::LayoutDetection) -> Self {
        Self {
            class_name: serde_json::to_value(val.class_name)
                .ok()
                .and_then(|s| s.as_str().map(String::from))
                .unwrap_or_default(),
            confidence: val.confidence,
            bbox: val.bbox.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::RecognizedTable> for RecognizedTable {
    fn from(val: kreuzberg::RecognizedTable) -> Self {
        Self {
            detection_bbox: val.detection_bbox.into(),
            cells: val.cells.into_iter().collect(),
            markdown: val.markdown,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::DetectionResult> for DetectionResult {
    fn from(val: kreuzberg::DetectionResult) -> Self {
        Self {
            page_width: val.page_width,
            page_height: val.page_height,
            detections: val.detections.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::pdf::embedded_files::EmbeddedFile> for EmbeddedFile {
    fn from(val: kreuzberg::pdf::embedded_files::EmbeddedFile) -> Self {
        Self {
            name: val.name,
            data: val.data.to_vec().into(),
            compressed_size: val.compressed_size as i64,
            mime_type: val.mime_type,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PdfMetadata> for kreuzberg::pdf::metadata::PdfMetadata {
    fn from(val: PdfMetadata) -> Self {
        Self {
            pdf_version: val.pdf_version,
            producer: val.producer,
            is_encrypted: val.is_encrypted,
            width: val.width,
            height: val.height,
            page_count: val.page_count,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<kreuzberg::pdf::metadata::PdfMetadata> for PdfMetadata {
    fn from(val: kreuzberg::pdf::metadata::PdfMetadata) -> Self {
        Self {
            pdf_version: val.pdf_version,
            producer: val.producer,
            is_encrypted: val.is_encrypted,
            width: val.width,
            height: val.height,
            page_count: val.page_count,
        }
    }
}

impl From<kreuzberg::ChunkSizing> for ChunkSizing {
    fn from(val: kreuzberg::ChunkSizing) -> Self {
        match val {
            kreuzberg::ChunkSizing::Characters => Self {
                type_tag: "characters".to_string(),
                ..Default::default()
            },
            kreuzberg::ChunkSizing::Tokenizer { model, cache_dir } => Self {
                type_tag: "tokenizer".to_string(),
                model: Some(model),
                cache_dir: cache_dir.map(|p| p.to_string_lossy().into_owned()),
            },
        }
    }
}

impl From<ChunkSizing> for kreuzberg::ChunkSizing {
    fn from(val: ChunkSizing) -> Self {
        match val.type_tag.as_str() {
            "characters" => kreuzberg::ChunkSizing::Characters,
            "tokenizer" => kreuzberg::ChunkSizing::Tokenizer {
                model: val.model.unwrap_or_default(),
                cache_dir: val.cache_dir.map(std::path::PathBuf::from),
            },
            _ => kreuzberg::ChunkSizing::default(),
        }
    }
}

impl From<kreuzberg::EmbeddingModelType> for EmbeddingModelType {
    fn from(val: kreuzberg::EmbeddingModelType) -> Self {
        match val {
            kreuzberg::EmbeddingModelType::Preset { name } => Self {
                type_tag: "preset".to_string(),
                name: Some(name),
                ..Default::default()
            },
            kreuzberg::EmbeddingModelType::Custom { model_id, dimensions } => Self {
                type_tag: "custom".to_string(),
                model_id: Some(model_id),
                dimensions: Some(dimensions as i64),
                ..Default::default()
            },
            kreuzberg::EmbeddingModelType::Llm { llm } => Self {
                type_tag: "llm".to_string(),
                llm: Some(llm.into()),
                ..Default::default()
            },
            kreuzberg::EmbeddingModelType::Plugin { name } => Self {
                type_tag: "plugin".to_string(),
                name: Some(name),
                ..Default::default()
            },
        }
    }
}

impl From<EmbeddingModelType> for kreuzberg::EmbeddingModelType {
    fn from(val: EmbeddingModelType) -> Self {
        match val.type_tag.as_str() {
            "preset" => kreuzberg::EmbeddingModelType::Preset {
                name: val.name.unwrap_or_default(),
            },
            "custom" => kreuzberg::EmbeddingModelType::Custom {
                model_id: val.model_id.unwrap_or_default(),
                dimensions: val.dimensions.map(|v| v as usize).unwrap_or_default(),
            },
            "llm" => kreuzberg::EmbeddingModelType::Llm {
                llm: val.llm.map(Into::into).unwrap_or_default(),
            },
            "plugin" => kreuzberg::EmbeddingModelType::Plugin {
                name: val.name.unwrap_or_default(),
            },
            _ => kreuzberg::EmbeddingModelType::default(),
        }
    }
}

impl From<kreuzberg::NodeContent> for NodeContent {
    fn from(val: kreuzberg::NodeContent) -> Self {
        match val {
            kreuzberg::NodeContent::Title { text } => Self {
                node_type_tag: "title".to_string(),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::Heading { level, text } => Self {
                node_type_tag: "heading".to_string(),
                level: Some(level),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::Paragraph { text } => Self {
                node_type_tag: "paragraph".to_string(),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::List { ordered } => Self {
                node_type_tag: "list".to_string(),
                ordered: Some(ordered),
                ..Default::default()
            },
            kreuzberg::NodeContent::ListItem { text } => Self {
                node_type_tag: "list_item".to_string(),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::Table { grid } => Self {
                node_type_tag: "table".to_string(),
                grid: Some(grid.into()),
                ..Default::default()
            },
            kreuzberg::NodeContent::Image {
                description,
                image_index,
                src,
            } => Self {
                node_type_tag: "image".to_string(),
                description,
                image_index,
                src,
                ..Default::default()
            },
            kreuzberg::NodeContent::Code { text, language } => Self {
                node_type_tag: "code".to_string(),
                text: Some(text),
                language,
                ..Default::default()
            },
            kreuzberg::NodeContent::Quote => Self {
                node_type_tag: "quote".to_string(),
                ..Default::default()
            },
            kreuzberg::NodeContent::Formula { text } => Self {
                node_type_tag: "formula".to_string(),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::Footnote { text } => Self {
                node_type_tag: "footnote".to_string(),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::Group {
                label,
                heading_level,
                heading_text,
            } => Self {
                node_type_tag: "group".to_string(),
                label,
                heading_level,
                heading_text,
                ..Default::default()
            },
            kreuzberg::NodeContent::PageBreak => Self {
                node_type_tag: "page_break".to_string(),
                ..Default::default()
            },
            kreuzberg::NodeContent::Slide { number, title } => Self {
                node_type_tag: "slide".to_string(),
                number: Some(number),
                title,
                ..Default::default()
            },
            kreuzberg::NodeContent::DefinitionList => Self {
                node_type_tag: "definition_list".to_string(),
                ..Default::default()
            },
            kreuzberg::NodeContent::DefinitionItem { term, definition } => Self {
                node_type_tag: "definition_item".to_string(),
                term: Some(term),
                definition: Some(definition),
                ..Default::default()
            },
            kreuzberg::NodeContent::Citation { key, text } => Self {
                node_type_tag: "citation".to_string(),
                key: Some(key),
                text: Some(text),
                ..Default::default()
            },
            kreuzberg::NodeContent::Admonition { kind, title } => Self {
                node_type_tag: "admonition".to_string(),
                kind: Some(kind),
                title,
                ..Default::default()
            },
            kreuzberg::NodeContent::RawBlock { format, content } => Self {
                node_type_tag: "raw_block".to_string(),
                format: Some(format),
                content: Some(content),
                ..Default::default()
            },
            kreuzberg::NodeContent::MetadataBlock { entries: _entries } => Self {
                node_type_tag: "metadata_block".to_string(),
                entries: None,
                ..Default::default()
            },
        }
    }
}

impl From<NodeContent> for kreuzberg::NodeContent {
    fn from(val: NodeContent) -> Self {
        match val.node_type_tag.as_str() {
            "title" => kreuzberg::NodeContent::Title {
                text: val.text.unwrap_or_default(),
            },
            "heading" => kreuzberg::NodeContent::Heading {
                level: val.level.unwrap_or_default(),
                text: val.text.unwrap_or_default(),
            },
            "paragraph" => kreuzberg::NodeContent::Paragraph {
                text: val.text.unwrap_or_default(),
            },
            "list" => kreuzberg::NodeContent::List {
                ordered: val.ordered.unwrap_or_default(),
            },
            "list_item" => kreuzberg::NodeContent::ListItem {
                text: val.text.unwrap_or_default(),
            },
            "table" => kreuzberg::NodeContent::Table {
                grid: val.grid.map(Into::into).unwrap_or_default(),
            },
            "image" => kreuzberg::NodeContent::Image {
                description: val.description,
                image_index: val.image_index,
                src: val.src,
            },
            "code" => kreuzberg::NodeContent::Code {
                text: val.text.unwrap_or_default(),
                language: val.language,
            },
            "quote" => kreuzberg::NodeContent::Quote,
            "formula" => kreuzberg::NodeContent::Formula {
                text: val.text.unwrap_or_default(),
            },
            "footnote" => kreuzberg::NodeContent::Footnote {
                text: val.text.unwrap_or_default(),
            },
            "group" => kreuzberg::NodeContent::Group {
                label: val.label,
                heading_level: val.heading_level,
                heading_text: val.heading_text,
            },
            "page_break" => kreuzberg::NodeContent::PageBreak,
            "slide" => kreuzberg::NodeContent::Slide {
                number: val.number.unwrap_or_default(),
                title: val.title,
            },
            "definition_list" => kreuzberg::NodeContent::DefinitionList,
            "definition_item" => kreuzberg::NodeContent::DefinitionItem {
                term: val.term.unwrap_or_default(),
                definition: val.definition.unwrap_or_default(),
            },
            "citation" => kreuzberg::NodeContent::Citation {
                key: val.key.unwrap_or_default(),
                text: val.text.unwrap_or_default(),
            },
            "admonition" => kreuzberg::NodeContent::Admonition {
                kind: val.kind.unwrap_or_default(),
                title: val.title,
            },
            "raw_block" => kreuzberg::NodeContent::RawBlock {
                format: val.format.unwrap_or_default(),
                content: val.content.unwrap_or_default(),
            },
            "metadata_block" => kreuzberg::NodeContent::MetadataBlock {
                entries: Default::default(),
            },
            _ => kreuzberg::NodeContent::default(),
        }
    }
}

impl From<kreuzberg::AnnotationKind> for AnnotationKind {
    fn from(val: kreuzberg::AnnotationKind) -> Self {
        match val {
            kreuzberg::AnnotationKind::Bold => Self {
                annotation_type_tag: "bold".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Italic => Self {
                annotation_type_tag: "italic".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Underline => Self {
                annotation_type_tag: "underline".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Strikethrough => Self {
                annotation_type_tag: "strikethrough".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Code => Self {
                annotation_type_tag: "code".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Subscript => Self {
                annotation_type_tag: "subscript".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Superscript => Self {
                annotation_type_tag: "superscript".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Link { url, title } => Self {
                annotation_type_tag: "link".to_string(),
                url: Some(url),
                title,
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Highlight => Self {
                annotation_type_tag: "highlight".to_string(),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Color { value } => Self {
                annotation_type_tag: "color".to_string(),
                value: Some(value),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::FontSize { value } => Self {
                annotation_type_tag: "font_size".to_string(),
                value: Some(value),
                ..Default::default()
            },
            kreuzberg::AnnotationKind::Custom { name, value } => Self {
                annotation_type_tag: "custom".to_string(),
                name: Some(name),
                value,
                ..Default::default()
            },
        }
    }
}

impl From<AnnotationKind> for kreuzberg::AnnotationKind {
    fn from(val: AnnotationKind) -> Self {
        match val.annotation_type_tag.as_str() {
            "bold" => kreuzberg::AnnotationKind::Bold,
            "italic" => kreuzberg::AnnotationKind::Italic,
            "underline" => kreuzberg::AnnotationKind::Underline,
            "strikethrough" => kreuzberg::AnnotationKind::Strikethrough,
            "code" => kreuzberg::AnnotationKind::Code,
            "subscript" => kreuzberg::AnnotationKind::Subscript,
            "superscript" => kreuzberg::AnnotationKind::Superscript,
            "link" => kreuzberg::AnnotationKind::Link {
                url: val.url.unwrap_or_default(),
                title: val.title,
            },
            "highlight" => kreuzberg::AnnotationKind::Highlight,
            "color" => kreuzberg::AnnotationKind::Color {
                value: val.value.unwrap_or_default(),
            },
            "font_size" => kreuzberg::AnnotationKind::FontSize {
                value: val.value.unwrap_or_default(),
            },
            "custom" => kreuzberg::AnnotationKind::Custom {
                name: val.name.unwrap_or_default(),
                value: val.value,
            },
            _ => kreuzberg::AnnotationKind::default(),
        }
    }
}

impl From<kreuzberg::FormatMetadata> for FormatMetadata {
    fn from(val: kreuzberg::FormatMetadata) -> Self {
        match val {
            kreuzberg::FormatMetadata::Pdf(_0) => Self {
                format_type_tag: "pdf".to_string(),
                pdf: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Docx(_0) => Self {
                format_type_tag: "docx".to_string(),
                docx: Some((*_0).into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Excel(_0) => Self {
                format_type_tag: "excel".to_string(),
                excel: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Email(_0) => Self {
                format_type_tag: "email".to_string(),
                email: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Pptx(_0) => Self {
                format_type_tag: "pptx".to_string(),
                pptx: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Archive(_0) => Self {
                format_type_tag: "archive".to_string(),
                archive: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Image(_0) => Self {
                format_type_tag: "image".to_string(),
                image: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Xml(_0) => Self {
                format_type_tag: "xml".to_string(),
                xml: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Text(_0) => Self {
                format_type_tag: "text".to_string(),
                text: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Html(_0) => Self {
                format_type_tag: "html".to_string(),
                html: Some((*_0).into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Ocr(_0) => Self {
                format_type_tag: "ocr".to_string(),
                ocr: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Csv(_0) => Self {
                format_type_tag: "csv".to_string(),
                csv: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Bibtex(_0) => Self {
                format_type_tag: "bibtex".to_string(),
                bibtex: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Citation(_0) => Self {
                format_type_tag: "citation".to_string(),
                citation: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::FictionBook(_0) => Self {
                format_type_tag: "fiction_book".to_string(),
                fiction_book: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Dbf(_0) => Self {
                format_type_tag: "dbf".to_string(),
                dbf: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Jats(_0) => Self {
                format_type_tag: "jats".to_string(),
                jats: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Epub(_0) => Self {
                format_type_tag: "epub".to_string(),
                epub: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Pst(_0) => Self {
                format_type_tag: "pst".to_string(),
                pst: Some(_0.into()),
                ..Default::default()
            },
            kreuzberg::FormatMetadata::Code(__0) => Self {
                format_type_tag: "code".to_string(),
                code: None,
                ..Default::default()
            },
        }
    }
}

impl From<FormatMetadata> for kreuzberg::FormatMetadata {
    fn from(val: FormatMetadata) -> Self {
        match val.format_type_tag.as_str() {
            "pdf" => kreuzberg::FormatMetadata::Pdf(val.pdf.map(Into::into).unwrap_or_default()),
            "docx" => kreuzberg::FormatMetadata::Docx(Box::new(val.docx.map(Into::into).unwrap_or_default())),
            "excel" => kreuzberg::FormatMetadata::Excel(val.excel.map(Into::into).unwrap_or_default()),
            "email" => kreuzberg::FormatMetadata::Email(val.email.map(Into::into).unwrap_or_default()),
            "pptx" => kreuzberg::FormatMetadata::Pptx(val.pptx.map(Into::into).unwrap_or_default()),
            "archive" => kreuzberg::FormatMetadata::Archive(val.archive.map(Into::into).unwrap_or_default()),
            "image" => kreuzberg::FormatMetadata::Image(val.image.map(Into::into).unwrap_or_default()),
            "xml" => kreuzberg::FormatMetadata::Xml(val.xml.map(Into::into).unwrap_or_default()),
            "text" => kreuzberg::FormatMetadata::Text(val.text.map(Into::into).unwrap_or_default()),
            "html" => kreuzberg::FormatMetadata::Html(Box::new(val.html.map(Into::into).unwrap_or_default())),
            "ocr" => kreuzberg::FormatMetadata::Ocr(val.ocr.map(Into::into).unwrap_or_default()),
            "csv" => kreuzberg::FormatMetadata::Csv(val.csv.map(Into::into).unwrap_or_default()),
            "bibtex" => kreuzberg::FormatMetadata::Bibtex(val.bibtex.map(Into::into).unwrap_or_default()),
            "citation" => kreuzberg::FormatMetadata::Citation(val.citation.map(Into::into).unwrap_or_default()),
            "fiction_book" => {
                kreuzberg::FormatMetadata::FictionBook(val.fiction_book.map(Into::into).unwrap_or_default())
            }
            "dbf" => kreuzberg::FormatMetadata::Dbf(val.dbf.map(Into::into).unwrap_or_default()),
            "jats" => kreuzberg::FormatMetadata::Jats(val.jats.map(Into::into).unwrap_or_default()),
            "epub" => kreuzberg::FormatMetadata::Epub(val.epub.map(Into::into).unwrap_or_default()),
            "pst" => kreuzberg::FormatMetadata::Pst(val.pst.map(Into::into).unwrap_or_default()),
            "code" => kreuzberg::FormatMetadata::Code(Default::default()),
            _ => kreuzberg::FormatMetadata::default(),
        }
    }
}

impl From<kreuzberg::OcrBoundingGeometry> for OcrBoundingGeometry {
    fn from(val: kreuzberg::OcrBoundingGeometry) -> Self {
        match val {
            kreuzberg::OcrBoundingGeometry::Rectangle {
                left,
                top,
                width,
                height,
            } => Self {
                type_tag: "rectangle".to_string(),
                left: Some(left),
                top: Some(top),
                width: Some(width),
                height: Some(height),
                ..Default::default()
            },
            kreuzberg::OcrBoundingGeometry::Quadrilateral { points: _points } => Self {
                type_tag: "quadrilateral".to_string(),
                points: None,
                ..Default::default()
            },
        }
    }
}

impl From<OcrBoundingGeometry> for kreuzberg::OcrBoundingGeometry {
    fn from(val: OcrBoundingGeometry) -> Self {
        match val.type_tag.as_str() {
            "rectangle" => kreuzberg::OcrBoundingGeometry::Rectangle {
                left: val.left.unwrap_or_default(),
                top: val.top.unwrap_or_default(),
                width: val.width.unwrap_or_default(),
                height: val.height.unwrap_or_default(),
            },
            "quadrilateral" => kreuzberg::OcrBoundingGeometry::Quadrilateral {
                points: Default::default(),
            },
            _ => kreuzberg::OcrBoundingGeometry::default(),
        }
    }
}

impl From<kreuzberg::DiffLine> for DiffLine {
    fn from(val: kreuzberg::DiffLine) -> Self {
        match val {
            kreuzberg::DiffLine::Context(_0) => Self {
                kind_tag: "context".to_string(),
                context: Some(_0),
                ..Default::default()
            },
            kreuzberg::DiffLine::Added(_0) => Self {
                kind_tag: "added".to_string(),
                added: Some(_0),
                ..Default::default()
            },
            kreuzberg::DiffLine::Removed(_0) => Self {
                kind_tag: "removed".to_string(),
                removed: Some(_0),
                ..Default::default()
            },
        }
    }
}

impl From<DiffLine> for kreuzberg::DiffLine {
    fn from(val: DiffLine) -> Self {
        match val.kind_tag.as_str() {
            "context" => kreuzberg::DiffLine::Context(val.context.unwrap_or_default()),
            "added" => kreuzberg::DiffLine::Added(val.added.unwrap_or_default()),
            "removed" => kreuzberg::DiffLine::Removed(val.removed.unwrap_or_default()),
            _ => kreuzberg::DiffLine::default(),
        }
    }
}

impl From<kreuzberg::RevisionAnchor> for RevisionAnchor {
    fn from(val: kreuzberg::RevisionAnchor) -> Self {
        match val {
            kreuzberg::RevisionAnchor::Paragraph { index } => Self {
                type_tag: "paragraph".to_string(),
                index: Some(index as i64),
                ..Default::default()
            },
            kreuzberg::RevisionAnchor::TableCell { row, col, table_index } => Self {
                type_tag: "table_cell".to_string(),
                row: Some(row as i64),
                col: Some(col as i64),
                table_index: Some(table_index as i64),
                ..Default::default()
            },
            kreuzberg::RevisionAnchor::Page { index } => Self {
                type_tag: "page".to_string(),
                index: Some(index as i64),
                ..Default::default()
            },
            kreuzberg::RevisionAnchor::Slide { index } => Self {
                type_tag: "slide".to_string(),
                index: Some(index as i64),
                ..Default::default()
            },
            kreuzberg::RevisionAnchor::Sheet { index, name } => Self {
                type_tag: "sheet".to_string(),
                index: Some(index as i64),
                name,
                ..Default::default()
            },
        }
    }
}

impl From<RevisionAnchor> for kreuzberg::RevisionAnchor {
    fn from(val: RevisionAnchor) -> Self {
        match val.type_tag.as_str() {
            "paragraph" => kreuzberg::RevisionAnchor::Paragraph {
                index: val.index.map(|v| v as usize).unwrap_or_default(),
            },
            "table_cell" => kreuzberg::RevisionAnchor::TableCell {
                row: val.row.map(|v| v as usize).unwrap_or_default(),
                col: val.col.map(|v| v as usize).unwrap_or_default(),
                table_index: val.table_index.map(|v| v as usize).unwrap_or_default(),
            },
            "page" => kreuzberg::RevisionAnchor::Page {
                index: val.index.map(|v| v as usize).unwrap_or_default(),
            },
            "slide" => kreuzberg::RevisionAnchor::Slide {
                index: val.index.map(|v| v as usize).unwrap_or_default(),
            },
            "sheet" => kreuzberg::RevisionAnchor::Sheet {
                index: val.index.map(|v| v as usize).unwrap_or_default(),
                name: val.name,
            },
            _ => kreuzberg::RevisionAnchor::default(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<CacheStats> for kreuzberg::CacheStats {
    fn from(val: CacheStats) -> Self {
        Self {
            total_files: val.total_files as usize,
            total_size_mb: val.total_size_mb,
            available_space_mb: val.available_space_mb,
            oldest_file_age_days: val.oldest_file_age_days,
            newest_file_age_days: val.newest_file_age_days,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<SupportedFormat> for kreuzberg::SupportedFormat {
    fn from(val: SupportedFormat) -> Self {
        Self {
            extension: val.extension,
            mime_type: val.mime_type,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<StructuredDataResult> for kreuzberg::extraction::structured::StructuredDataResult {
    fn from(val: StructuredDataResult) -> Self {
        Self {
            content: val.content,
            format: val.format.into(),
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            text_fields: val.text_fields.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<XlsxAppProperties> for kreuzberg::extraction::office_metadata::app_properties::XlsxAppProperties {
    fn from(val: XlsxAppProperties) -> Self {
        Self {
            application: val.application,
            app_version: val.app_version,
            doc_security: val.doc_security,
            scale_crop: val.scale_crop,
            links_up_to_date: val.links_up_to_date,
            shared_doc: val.shared_doc,
            hyperlinks_changed: val.hyperlinks_changed,
            company: val.company,
            worksheet_names: val.worksheet_names.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<PptxAppProperties> for kreuzberg::extraction::office_metadata::app_properties::PptxAppProperties {
    fn from(val: PptxAppProperties) -> Self {
        Self {
            application: val.application,
            app_version: val.app_version,
            total_time: val.total_time,
            company: val.company,
            doc_security: val.doc_security,
            scale_crop: val.scale_crop,
            links_up_to_date: val.links_up_to_date,
            shared_doc: val.shared_doc,
            hyperlinks_changed: val.hyperlinks_changed,
            slides: val.slides,
            notes: val.notes,
            hidden_slides: val.hidden_slides,
            multimedia_clips: val.multimedia_clips,
            presentation_format: val.presentation_format,
            slide_titles: val.slide_titles.into_iter().collect(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<ExcelWorkbook> for kreuzberg::ExcelWorkbook {
    fn from(val: ExcelWorkbook) -> Self {
        Self {
            sheets: val.sheets.into_iter().map(Into::into).collect(),
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
            revisions: val.revisions.map(|v| v.into_iter().map(Into::into).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ExcelSheet> for kreuzberg::ExcelSheet {
    fn from(val: ExcelSheet) -> Self {
        Self {
            name: val.name,
            markdown: val.markdown,
            row_count: val.row_count as usize,
            col_count: val.col_count as usize,
            cell_count: val.cell_count as usize,
            table_cells: val.table_cells.map(|v| v.into_iter().collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<XmlExtractionResult> for kreuzberg::XmlExtractionResult {
    fn from(val: XmlExtractionResult) -> Self {
        Self {
            content: val.content,
            element_count: val.element_count as usize,
            unique_elements: val.unique_elements.into_iter().collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TextExtractionResult> for kreuzberg::TextExtractionResult {
    fn from(val: TextExtractionResult) -> Self {
        Self {
            content: val.content,
            line_count: val.line_count as usize,
            word_count: val.word_count as usize,
            character_count: val.character_count as usize,
            headers: val.headers.map(|v| v.into_iter().collect()),
            links: Default::default(),
            code_blocks: Default::default(),
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<PptxExtractionResult> for kreuzberg::PptxExtractionResult {
    fn from(val: PptxExtractionResult) -> Self {
        Self {
            content: val.content,
            metadata: val.metadata.into(),
            slide_count: val.slide_count as usize,
            image_count: val.image_count as usize,
            table_count: val.table_count as usize,
            images: val.images.into_iter().map(Into::into).collect(),
            page_structure: val.page_structure.map(Into::into),
            page_contents: val.page_contents.map(|v| v.into_iter().map(Into::into).collect()),
            document: val.document.map(Into::into),
            hyperlinks: Default::default(),
            office_metadata: val
                .office_metadata
                .into_iter()
                .map(|(k, v)| (k.into(), v.into()))
                .collect(),
            revisions: val.revisions.map(|v| v.into_iter().map(Into::into).collect()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EmailExtractionResult> for kreuzberg::EmailExtractionResult {
    fn from(val: EmailExtractionResult) -> Self {
        Self {
            subject: val.subject,
            from_email: val.from_email,
            to_emails: val.to_emails.into_iter().collect(),
            cc_emails: val.cc_emails.into_iter().collect(),
            bcc_emails: val.bcc_emails.into_iter().collect(),
            date: val.date,
            message_id: val.message_id,
            plain_text: val.plain_text,
            html_content: val.html_content,
            content: val.content,
            attachments: val.attachments.into_iter().map(Into::into).collect(),
            metadata: val.metadata.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EmailAttachment> for kreuzberg::EmailAttachment {
    fn from(val: EmailAttachment) -> Self {
        Self {
            name: val.name,
            filename: val.filename,
            mime_type: val.mime_type,
            size: val.size.map(|v| v as usize),
            is_image: val.is_image,
            data: val.data.map(|v| v.to_vec().into()),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<TableCell> for kreuzberg::TableCell {
    fn from(val: TableCell) -> Self {
        Self {
            content: val.content,
            row_span: val.row_span,
            col_span: val.col_span,
            is_header: val.is_header,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<DetectResponse> for kreuzberg::api::DetectResponse {
    fn from(val: DetectResponse) -> Self {
        Self {
            mime_type: val.mime_type,
            filename: val.filename,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<ModelPaths> for kreuzberg::ModelPaths {
    fn from(val: ModelPaths) -> Self {
        Self {
            det_model: val.det_model.into(),
            cls_model: val.cls_model.into(),
            rec_model: val.rec_model.into(),
            dict_file: val.dict_file.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<OrientationResult> for kreuzberg::OrientationResult {
    fn from(val: OrientationResult) -> Self {
        Self {
            degrees: val.degrees,
            confidence: val.confidence,
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<BBox> for kreuzberg::BBox {
    fn from(val: BBox) -> Self {
        Self {
            x1: val.x1,
            y1: val.y1,
            x2: val.x2,
            y2: val.y2,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<LayoutDetection> for kreuzberg::LayoutDetection {
    fn from(val: LayoutDetection) -> Self {
        Self {
            class_name: match val.class_name.as_str() {
                "caption" => kreuzberg::LayoutClass::Caption,
                "footnote" => kreuzberg::LayoutClass::Footnote,
                "formula" => kreuzberg::LayoutClass::Formula,
                "list_item" => kreuzberg::LayoutClass::ListItem,
                "page_footer" => kreuzberg::LayoutClass::PageFooter,
                "page_header" => kreuzberg::LayoutClass::PageHeader,
                "picture" => kreuzberg::LayoutClass::Picture,
                "section_header" => kreuzberg::LayoutClass::SectionHeader,
                "table" => kreuzberg::LayoutClass::Table,
                "text" => kreuzberg::LayoutClass::Text,
                "title" => kreuzberg::LayoutClass::Title,
                "document_index" => kreuzberg::LayoutClass::DocumentIndex,
                "code" => kreuzberg::LayoutClass::Code,
                "checkbox_selected" => kreuzberg::LayoutClass::CheckboxSelected,
                "checkbox_unselected" => kreuzberg::LayoutClass::CheckboxUnselected,
                "form" => kreuzberg::LayoutClass::Form,
                "key_value_region" => kreuzberg::LayoutClass::KeyValueRegion,
                _ => Default::default(),
            },
            confidence: val.confidence,
            bbox: val.bbox.into(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<RecognizedTable> for kreuzberg::RecognizedTable {
    fn from(val: RecognizedTable) -> Self {
        Self {
            detection_bbox: val.detection_bbox.into(),
            cells: val.cells.into_iter().collect(),
            markdown: val.markdown,
        }
    }
}

#[allow(clippy::useless_conversion)]
impl From<DetectionResult> for kreuzberg::DetectionResult {
    fn from(val: DetectionResult) -> Self {
        Self {
            page_width: val.page_width,
            page_height: val.page_height,
            detections: val.detections.into_iter().map(Into::into).collect(),
        }
    }
}

#[allow(clippy::redundant_closure, clippy::useless_conversion)]
impl From<EmbeddedFile> for kreuzberg::pdf::embedded_files::EmbeddedFile {
    fn from(val: EmbeddedFile) -> Self {
        Self {
            name: val.name,
            data: val.data.to_vec().into(),
            compressed_size: val.compressed_size as usize,
            mime_type: val.mime_type,
        }
    }
}

/// Convert a `kreuzberg::error::KreuzbergError` error to a PHP exception.
#[allow(dead_code)]
fn kreuzberg_error_to_php_err(e: kreuzberg::error::KreuzbergError) -> ext_php_rs::exception::PhpException {
    let msg = e.to_string();
    #[allow(unreachable_patterns)]
    match &e {
        kreuzberg::error::KreuzbergError::Io(..) => {
            ext_php_rs::exception::PhpException::default(format!("[Io] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Parsing { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Parsing] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Ocr { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Ocr] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Validation { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Validation] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Cache { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Cache] {}", msg))
        }
        kreuzberg::error::KreuzbergError::ImageProcessing { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[ImageProcessing] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Serialization { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Serialization] {}", msg))
        }
        kreuzberg::error::KreuzbergError::MissingDependency(..) => {
            ext_php_rs::exception::PhpException::default(format!("[MissingDependency] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Plugin { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Plugin] {}", msg))
        }
        kreuzberg::error::KreuzbergError::LockPoisoned(..) => {
            ext_php_rs::exception::PhpException::default(format!("[LockPoisoned] {}", msg))
        }
        kreuzberg::error::KreuzbergError::UnsupportedFormat(..) => {
            ext_php_rs::exception::PhpException::default(format!("[UnsupportedFormat] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Embedding { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Embedding] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Timeout { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Timeout] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Cancelled => {
            ext_php_rs::exception::PhpException::default(format!("[Cancelled] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Security { .. } => {
            ext_php_rs::exception::PhpException::default(format!("[Security] {}", msg))
        }
        kreuzberg::error::KreuzbergError::Other(..) => {
            ext_php_rs::exception::PhpException::default(format!("[Other] {}", msg))
        }
        _ => ext_php_rs::exception::PhpException::default(msg),
    }
}

mod serde_defaults {
    pub fn bool_true() -> bool {
        true
    }
    pub fn max_archive_size() -> i64 {
        500 * 1024 * 1024
    }
    pub fn max_compression_ratio() -> i64 {
        100
    }
    pub fn max_files_in_archive() -> i64 {
        10_000
    }
    pub fn max_nesting_depth() -> i64 {
        1024
    }
    pub fn max_entity_length() -> i64 {
        1024 * 1024
    }
    pub fn max_content_size() -> i64 {
        100 * 1024 * 1024
    }
    pub fn max_iterations() -> i64 {
        10_000_000
    }
    pub fn max_xml_depth() -> i64 {
        1024
    }
    pub fn max_table_cells() -> i64 {
        100_000
    }
}

#[php_module]
pub fn get_module(module: ModuleBuilder) -> ModuleBuilder {
    module
        .class::<CacheStats>()
        .class::<AccelerationConfig>()
        .class::<ContentFilterConfig>()
        .class::<EmailConfig>()
        .class::<ExtractionConfig>()
        .class::<FileExtractionConfig>()
        .class::<BatchBytesItem>()
        .class::<BatchFileItem>()
        .class::<ImageExtractionConfig>()
        .class::<TokenReductionOptions>()
        .class::<LanguageDetectionConfig>()
        .class::<HtmlOutputConfig>()
        .class::<LayoutDetectionConfig>()
        .class::<LlmConfig>()
        .class::<StructuredExtractionConfig>()
        .class::<OcrQualityThresholds>()
        .class::<OcrPipelineStage>()
        .class::<OcrPipelineConfig>()
        .class::<OcrConfig>()
        .class::<PageConfig>()
        .class::<PdfConfig>()
        .class::<HierarchyConfig>()
        .class::<PostProcessorConfig>()
        .class::<ChunkingConfig>()
        .class::<EmbeddingConfig>()
        .class::<TreeSitterConfig>()
        .class::<TreeSitterProcessConfig>()
        .class::<SupportedFormat>()
        .class::<ServerConfig>()
        .class::<StructuredDataResult>()
        .class::<DocxAppProperties>()
        .class::<XlsxAppProperties>()
        .class::<PptxAppProperties>()
        .class::<CoreProperties>()
        .class::<SecurityLimits>()
        .class::<TokenReductionConfig>()
        .class::<PdfAnnotation>()
        .class::<DjotContent>()
        .class::<FormattedBlock>()
        .class::<InlineElement>()
        .class::<DjotImage>()
        .class::<DjotLink>()
        .class::<Footnote>()
        .class::<DocumentStructure>()
        .class::<DocumentRelationship>()
        .class::<DocumentNode>()
        .class::<TableGrid>()
        .class::<GridCell>()
        .class::<TextAnnotation>()
        .class::<ExtractionResult>()
        .class::<ArchiveEntry>()
        .class::<ProcessingWarning>()
        .class::<LlmUsage>()
        .class::<Chunk>()
        .class::<HeadingContext>()
        .class::<HeadingLevel>()
        .class::<ChunkMetadata>()
        .class::<ExtractedImage>()
        .class::<BoundingBox>()
        .class::<ElementMetadata>()
        .class::<Element>()
        .class::<ExcelWorkbook>()
        .class::<ExcelSheet>()
        .class::<XmlExtractionResult>()
        .class::<TextExtractionResult>()
        .class::<PptxExtractionResult>()
        .class::<EmailExtractionResult>()
        .class::<EmailAttachment>()
        .class::<OcrExtractionResult>()
        .class::<OcrTable>()
        .class::<OcrTableBoundingBox>()
        .class::<ImagePreprocessingConfig>()
        .class::<TesseractConfig>()
        .class::<ImagePreprocessingMetadata>()
        .class::<Metadata>()
        .class::<ExcelMetadata>()
        .class::<EmailMetadata>()
        .class::<ArchiveMetadata>()
        .class::<ImageMetadata>()
        .class::<XmlMetadata>()
        .class::<TextMetadata>()
        .class::<HeaderMetadata>()
        .class::<LinkMetadata>()
        .class::<ImageMetadataType>()
        .class::<StructuredData>()
        .class::<HtmlMetadata>()
        .class::<OcrMetadata>()
        .class::<ErrorMetadata>()
        .class::<PptxMetadata>()
        .class::<DocxMetadata>()
        .class::<CsvMetadata>()
        .class::<BibtexMetadata>()
        .class::<CitationMetadata>()
        .class::<YearRange>()
        .class::<FictionBookMetadata>()
        .class::<DbfMetadata>()
        .class::<DbfFieldInfo>()
        .class::<JatsMetadata>()
        .class::<ContributorRole>()
        .class::<EpubMetadata>()
        .class::<PstMetadata>()
        .class::<OcrConfidence>()
        .class::<OcrRotation>()
        .class::<OcrElement>()
        .class::<OcrElementConfig>()
        .class::<PageStructure>()
        .class::<PageBoundary>()
        .class::<PageInfo>()
        .class::<PageContent>()
        .class::<LayoutRegion>()
        .class::<PageHierarchy>()
        .class::<HierarchicalBlock>()
        .class::<CellChange>()
        .class::<DocumentRevision>()
        .class::<RevisionDelta>()
        .class::<Table>()
        .class::<TableCell>()
        .class::<ExtractedUri>()
        .class::<DetectResponse>()
        .class::<DiffOptions>()
        .class::<ExtractionDiff>()
        .class::<DiffHunk>()
        .class::<TableDiff>()
        .class::<EmbeddedChanges>()
        .class::<EmbeddedDiff>()
        .class::<EmbeddingPreset>()
        .class::<YakeParams>()
        .class::<RakeParams>()
        .class::<KeywordConfig>()
        .class::<Keyword>()
        .class::<PaddleOcrConfig>()
        .class::<ModelPaths>()
        .class::<OrientationResult>()
        .class::<BBox>()
        .class::<LayoutDetection>()
        .class::<RecognizedTable>()
        .class::<DetectionResult>()
        .class::<EmbeddedFile>()
        .class::<PdfMetadata>()
        .class::<KreuzbergApi>()
        .class::<ChunkSizing>()
        .class::<EmbeddingModelType>()
        .class::<NodeContent>()
        .class::<AnnotationKind>()
        .class::<FormatMetadata>()
        .class::<OcrBoundingGeometry>()
        .class::<DiffLine>()
        .class::<RevisionAnchor>()
}
