import contextlib
import copy
import functools
import logging
import os
import re
from collections.abc import Iterable, Mapping
from pathlib import Path
from types import MethodType
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    List,
    Literal,
    Optional,
    Sequence,
    Tuple,
    Type,
    Union,
    cast,
    overload,
)

import pdfplumber
from PIL import Image

from natural_pdf.classification.accessors import ClassificationResultAccessorMixin
from natural_pdf.elements.base import extract_bbox
from natural_pdf.elements.element_collection import ElementCollection
from natural_pdf.elements.region import Region
from natural_pdf.selectors.host_mixin import SelectorHostMixin
from natural_pdf.selectors.parser import parse_selector
from natural_pdf.tables.result import TableResult

if TYPE_CHECKING:
    from pdfplumber.page import Page as PdfPlumberPage

    from natural_pdf.core.highlighting_service import HighlightContext, HighlightingService
    from natural_pdf.core.pdf import PDF
    from natural_pdf.describe.summary import InspectionSummary
    from natural_pdf.elements.base import Element
    from natural_pdf.extraction.result import StructuredDataResult
else:  # pragma: no cover - runtime typing helper
    PdfPlumberPage = Any  # type: ignore[assignment]

# # New Imports

# # Deskew Imports (Conditional)

from natural_pdf.analyzers.layout.layout_analyzer import LayoutAnalyzer
from natural_pdf.analyzers.layout.layout_options import LayoutOptions
from natural_pdf.analyzers.text_options import TextStyleOptions
from natural_pdf.analyzers.text_structure import TextStyleAnalyzer
from natural_pdf.core.context import PDFContext

# Add new import
from natural_pdf.core.crop_utils import resolve_crop_bbox
from natural_pdf.core.element_manager import ElementManager
from natural_pdf.core.interfaces import Bounds, SupportsGeometry, SupportsSections
from natural_pdf.core.mixins import SinglePageContextMixin
from natural_pdf.core.render_spec import RenderSpec, Visualizable
from natural_pdf.core.selector_utils import _apply_relational_post_pseudos
from natural_pdf.deskew import run_deskew_apply, run_deskew_detect
from natural_pdf.elements.base import Element  # Import base element
from natural_pdf.elements.text import TextElement
from natural_pdf.ocr.ocr_manager import (
    normalize_ocr_options,
    resolve_ocr_device,
    resolve_ocr_engine_name,
    resolve_ocr_languages,
    resolve_ocr_min_confidence,
    run_ocr_apply,
    run_ocr_extract,
)

# Service modules are loaded lazily via the registry in natural_pdf.services.registry
from natural_pdf.services.base import ServiceHostMixin, resolve_service

# # Import new utils
from natural_pdf.text.operations import (
    _create_alt_text_char_dict,
    apply_bidi_processing,
    filter_chars_spatially,
    generate_text_layout,
)
from natural_pdf.text.operations import normalize_whitespace as _normalize_whitespace

# Viewer widget support is lazy-loaded to avoid importing ipywidgets/IPython at startup

# End Deskew Imports

logger = logging.getLogger(__name__)


def _jaro_winkler_similarity(s1: str, s2: str, prefix_weight: float = 0.1) -> float:
    """
    Compute Jaro-Winkler similarity between two strings.

    Args:
        s1: First string.
        s2: Second string.
        prefix_weight: Scaling factor for common prefix bonus (default 0.1).

    Returns:
        Similarity score in the range [0, 1].
    """
    if s1 == s2:
        return 1.0

    len1, len2 = len(s1), len(s2)
    if len1 == 0 or len2 == 0:
        return 0.0

    match_distance = max(len1, len2) // 2 - 1
    if match_distance < 0:
        match_distance = 0

    s1_matches = [False] * len1
    s2_matches = [False] * len2

    matches = 0
    for i in range(len1):
        start = max(0, i - match_distance)
        end = min(i + match_distance + 1, len2)
        for j in range(start, end):
            if s2_matches[j]:
                continue
            if s1[i] != s2[j]:
                continue
            s1_matches[i] = True
            s2_matches[j] = True
            matches += 1
            break

    if matches == 0:
        return 0.0

    transpositions = 0
    k = 0
    for i in range(len1):
        if not s1_matches[i]:
            continue
        while not s2_matches[k]:
            k += 1
        if s1[i] != s2[k]:
            transpositions += 1
        k += 1
    transpositions /= 2

    jaro = ((matches / len1) + (matches / len2) + (matches - transpositions) / matches) / 3.0

    prefix = 0
    for c1, c2 in zip(s1, s2):
        if c1 != c2:
            break
        prefix += 1
        if prefix == 4:
            break

    jaro_winkler = jaro + prefix * prefix_weight * (1.0 - jaro)
    return max(0.0, min(1.0, jaro_winkler))


class Page(
    ClassificationResultAccessorMixin,
    ServiceHostMixin,
    SelectorHostMixin,
    SinglePageContextMixin,
    SupportsSections,
    Visualizable,
):
    """Enhanced Page wrapper built on top of pdfplumber.Page.

    This class provides a fluent interface for working with PDF pages,
    with improved selection, navigation, extraction, and question-answering capabilities.
    It integrates multiple analysis capabilities through mixins and provides spatial
    navigation with CSS-like selectors.

    The Page class serves as the primary interface for document analysis, offering:
    - Element selection and spatial navigation
    - OCR and layout analysis integration
    - Table detection and extraction
    - AI-powered classification and data extraction
    - Visual debugging with highlighting and cropping
    - Text style analysis and structure detection

    Attributes:
        index: Zero-based index of this page in the PDF.
        number: One-based page number (index + 1).
        width: Page width in points.
        height: Page height in points.
        bbox: Bounding box tuple (x0, top, x1, bottom) of the page.
        chars: Collection of character elements on the page.
        words: Collection of word elements on the page.
        lines: Collection of line elements on the page.
        rects: Collection of rectangle elements on the page.
        images: Collection of image elements on the page.
        metadata: Dictionary for storing analysis results and custom data.

    Example:
        Basic usage:
        ```python
        pdf = npdf.PDF("document.pdf")
        page = pdf.pages[0]

        # Find elements with CSS-like selectors
        headers = page.find_all('text[size>12]:bold')
        summaries = page.find('text:contains("Summary")')

        # Spatial navigation
        content_below = summaries.below(until='text[size>12]:bold')

        # Table extraction
        tables = page.extract_table()
        ```

        Advanced usage:
        ```python
        # Apply OCR if needed
        page.apply_ocr(engine='easyocr', resolution=300)

        # Layout analysis
        page.analyze_layout(engine='yolo')

        # AI-powered extraction
        data = page.extract_structured_data(MySchema)

        # Visual debugging
        page.find('text:contains("Important")').show()
        ```
    """

    def __init__(
        self,
        page: "PdfPlumberPage",
        parent: "PDF",
        index: int,
        font_attrs=None,
        load_text: bool = True,
        context: Optional[PDFContext] = None,
    ):
        """Initialize a page wrapper.

        Creates an enhanced Page object that wraps a pdfplumber page with additional
        functionality for spatial navigation, analysis, and AI-powered extraction.

        Args:
            page: The underlying pdfplumber page object that provides raw PDF data.
            parent: Parent PDF object that contains this page and provides access
                to managers and global settings.
            index: Zero-based index of this page in the PDF document.
            font_attrs: List of font attributes to consider when grouping characters
                into words. Common attributes include ['fontname', 'size', 'flags'].
                If None, uses default character-to-word grouping rules.
            load_text: If True, load and process text elements from the PDF's text layer.
                If False, skip text layer processing (useful for OCR-only workflows).

        Note:
            This constructor is typically called automatically when accessing pages
            through the PDF.pages collection. Direct instantiation is rarely needed.

        Example:
            ```python
            # Pages are usually accessed through the PDF object
            pdf = npdf.PDF("document.pdf")
            page = pdf.pages[0]  # Page object created automatically

            # Direct construction (advanced usage)
            import pdfplumber
            with pdfplumber.open("document.pdf") as plumber_pdf:
                plumber_page = plumber_pdf.pages[0]
                page = Page(plumber_page, pdf, 0, load_text=True)
            ```
        """
        resolved_context = context or getattr(parent, "_context", PDFContext.with_defaults())
        self._init_service_host(resolved_context)

        self._page = page
        self._parent = parent
        self._index = index
        self._load_text = load_text
        self._text_styles_summary: Dict[str, Any] = {}
        self._text_styles = None  # Lazy-loaded text style analyzer results
        self._exclusions = []  # List to store exclusion functions/regions
        self._skew_angle: Optional[float] = None  # Stores detected skew angle

        self.metadata: Dict[str, Any] = {}

        # Region management
        self._regions = {
            "detected": [],  # Layout detection results
            "named": {},  # Named regions (name -> region)
        }

        if not hasattr(self._parent, "_config"):
            raise AttributeError(
                "Parent PDF is missing _config; cannot initialise Page configuration"
            )

        # Page-scoped configuration begins as a shallow copy of the parent PDF-level
        # configuration so that auto-computed tolerances or other page-specific
        # values do not overwrite siblings.
        self._config = dict(self._parent._config)

        # Initialize ElementManager, passing font_attrs
        self._element_mgr: ElementManager = ElementManager(
            self, font_attrs=font_attrs, load_text=self._load_text
        )
        # self._highlighter = HighlightingService(self) # REMOVED - Use property accessor
        if not hasattr(self, "metadata") or self.metadata is None:
            self.metadata = {}
        self.metadata.setdefault("analysis", {})

        # Initialize the internal variable with a single underscore
        self._layout_analyzer = None

        self._load_elements()
        self._to_image_cache: Dict[tuple, Optional["Image.Image"]] = {}

        # Flag to prevent infinite recursion when computing exclusions
        self._computing_exclusions = False

    def _get_render_specs(
        self,
        mode: Literal["show", "render"] = "show",
        color: Optional[Union[str, Tuple[int, int, int]]] = None,
        highlights: Optional[List[Dict[str, Any]]] = None,
        crop: Union[bool, Literal["content"]] = False,
        crop_bbox: Optional[Tuple[float, float, float, float]] = None,
        **kwargs,
    ) -> List[RenderSpec]:
        """Get render specifications for this page.

        Args:
            mode: Rendering mode - 'show' includes page highlights, 'render' is clean
            color: Default color for highlights in show mode
            highlights: Additional highlight groups to show
            crop: Whether to crop the page
            crop_bbox: Explicit crop bounds
            **kwargs: Additional parameters

        Returns:
            List containing a single RenderSpec for this page
        """
        spec = RenderSpec(page=self)

        elements: Optional[List[Element]] = None
        content_bbox: Optional[Tuple[float, float, float, float]] = None

        def ensure_content_bbox() -> Optional[Tuple[float, float, float, float]]:
            nonlocal elements, content_bbox
            if content_bbox is not None:
                return content_bbox
            if elements is None:
                elements = self.get_elements(apply_exclusions=False)
            if not elements:
                return None
            x_coords: List[float] = []
            y_coords: List[float] = []
            for elem in elements:
                if hasattr(elem, "bbox") and elem.bbox:
                    x0, y0, x1, y1 = elem.bbox
                    x_coords.extend([x0, x1])
                    y_coords.extend([y0, y1])
            if x_coords and y_coords:
                content_bbox = (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
                return content_bbox
            return None

        spec.crop_bbox = resolve_crop_bbox(
            width=self.width,
            height=self.height,
            crop=crop,
            crop_bbox=crop_bbox,
            content_bbox_fn=ensure_content_bbox,
        )

        # Add highlights in show mode
        if mode == "show":
            # Add page's persistent highlights if any
            page_highlights = self._highlighter.get_highlights_for_page(self.index)
            for highlight in page_highlights:
                spec.add_highlight(
                    bbox=highlight.bbox,
                    polygon=highlight.polygon,
                    color=highlight.color,
                    label=highlight.label,
                    element=None,  # Persistent highlights don't have element refs
                    attributes_to_draw=highlight.attributes if highlight.attributes else None,
                    quantitative_metadata=highlight.quantitative_metadata,
                )

            # Add additional highlight groups if provided
            if highlights:
                for group in highlights:
                    raw_elements = group.get("elements")
                    if not raw_elements:
                        continue

                    if isinstance(raw_elements, ElementCollection):
                        elements_iter: Iterable[Any] = raw_elements.elements
                    elif isinstance(raw_elements, Iterable):
                        elements_iter = cast(Iterable[Any], raw_elements)
                    else:
                        elements_iter = (raw_elements,)

                    group_color = group.get("color", color)
                    group_label = group.get("label")

                    for elem in elements_iter:
                        spec.add_highlight(element=elem, color=group_color, label=group_label)

            # Handle exclusions visualization
            exclusions_param = kwargs.get("exclusions")
            if exclusions_param:
                # Get exclusion regions
                exclusion_regions = self._get_exclusion_regions(include_callable=True)

                if exclusion_regions:
                    # Determine color for exclusions
                    exclusion_color = (
                        exclusions_param if isinstance(exclusions_param, str) else "red"
                    )

                    # Add exclusion regions as highlights
                    for region in exclusion_regions:
                        spec.add_highlight(
                            element=region,
                            color=exclusion_color,
                            label=f"Exclusion: {region.label or 'unnamed'}",
                        )

        return [spec]

    def to_region(self) -> Region:
        """Return a Region covering the full page."""
        return self.region(0, 0, self.width, self.height)

    qa_target = "page"

    def _qa_context_page_number(self) -> int:
        return self.number

    def _qa_source_elements(self) -> ElementCollection:
        return ElementCollection([])

    def _qa_target_region(self) -> Region:
        return self.to_region()

    def _element_to_region(self, element: Any, label: Optional[str] = None) -> Optional[Region]:
        bbox = extract_bbox(element)
        if bbox is None:
            return None
        return Region(self, bbox, label=label)

    def _exclusion_element_manager(self) -> ElementManager:
        return self._element_mgr

    def _get_element_loader(self):
        """Internal accessor for the ElementLoader shared across regions/collections."""
        return self._element_mgr.element_loader

    def _get_decoration_detector(self):
        """Internal accessor for the DecorationDetector used during element loading."""
        return self._element_mgr._decorations

    def _invalidate_exclusion_cache(self) -> None:
        if self._element_mgr:
            self._element_mgr.invalidate_cache()

    @property
    def pdf(self) -> "PDF":
        """Provides public access to the parent PDF object."""
        return self._parent

    @property
    def number(self) -> int:
        """Get page number (1-based)."""
        return self._page.page_number

    @property
    def page_number(self) -> int:
        """Get page number (1-based)."""
        return self._page.page_number

    @property
    def index(self) -> int:
        """Get page index (0-based)."""
        return self._index

    @property
    def width(self) -> float:
        """Get page width."""
        return self._page.width

    @property
    def height(self) -> float:
        """Get page height."""
        return self._page.height

    @property
    def _highlighter(self) -> "HighlightingService":
        """Provides access to the parent PDF's HighlightingService."""
        if not hasattr(self._parent, "highlighter"):
            # This should ideally not happen if PDF.__init__ works correctly
            raise AttributeError("Parent PDF object does not have a 'highlighter' attribute.")
        return self._parent.highlighter

    def get_highlighter(self) -> "HighlightingService":
        """Expose the page-level HighlightingService for Visualizable consumers."""
        return self._highlighter

    def _context_page(self) -> "Page":
        return self

    def clear_exclusions(self) -> "Page":
        """
        Clear all exclusions from the page.
        """
        self._exclusions = []
        return self

    def add_exclusion(
        self,
        exclusion: Any,
        label: Optional[str] = None,
        method: str = "region",
    ):
        """Register an exclusion on the host via the exclusion service."""

        self.services.exclusion.add_exclusion(self, exclusion, label=label, method=method)
        return self

    @contextlib.contextmanager
    def without_exclusions(self):
        """
        Context manager that temporarily disables exclusion processing.

        This prevents infinite recursion when exclusion callables themselves
        use find() operations. While in this context, all find operations
        will skip exclusion filtering.

        Example:
            ```python
            # This exclusion would normally cause infinite recursion:
            page.add_exclusion(lambda p: p.find("text:contains('Header')").expand())

            # But internally, it's safe because we use:
            with page.without_exclusions():
                region = exclusion_callable(page)
            ```

        Yields:
            The page object with exclusions temporarily disabled.
        """
        old_value = self._computing_exclusions
        self._computing_exclusions = True
        try:
            yield self
        finally:
            self._computing_exclusions = old_value

    def add_region(
        self, region: "Region", name: Optional[str] = None, *, source: Optional[str] = None
    ) -> "Page":
        """
        Add a region to the page.

        Args:
            region: Region object to add
            name: Optional name for the region
            source: Optional provenance label; if provided it will be recorded on the region.

        Returns:
            Self for method chaining
        """
        # Check if it's actually a Region object
        if not isinstance(region, Region):
            raise TypeError("region must be a Region object")

        # Respect an explicitly provided source, otherwise keep any existing label.
        if source is not None:
            region.source = source
        elif getattr(region, "source", None) is None:
            region.source = "named"

        if name:
            region.name = name
            # Add to named regions dictionary (overwriting if name already exists)
            self._regions["named"][name] = region
        else:
            # Add to detected regions list (unnamed but registered)
            self._regions["detected"].append(region)

        # Add to element manager for selector queries
        self._element_mgr.add_region(region)

        return self

    def add_regions(
        self,
        regions: List["Region"],
        prefix: Optional[str] = None,
        *,
        source: Optional[str] = None,
    ) -> "Page":
        """
        Add multiple regions to the page.

        Args:
            regions: List of Region objects to add
            prefix: Optional prefix for automatic naming (regions will be named prefix_1, prefix_2, etc.)
            source: Optional provenance label applied to each region.

        Returns:
            Self for method chaining
        """
        if prefix:
            # Add with automatic sequential naming
            for i, region in enumerate(regions):
                self.add_region(region, name=f"{prefix}_{i+1}", source=source)
        else:
            # Add without names
            for region in regions:
                self.add_region(region, source=source)

        return self

    # Element manager facade helpers
    def ensure_elements_loaded(self) -> None:
        """Force the underlying element manager to load elements."""
        self._element_mgr.load_elements()

    def invalidate_element_cache(self) -> None:
        """Invalidate the cached elements so they are reloaded on next access."""
        self._element_mgr.invalidate_cache()

    def has_element_cache(self) -> bool:
        """Return True if the element manager currently holds any elements."""
        return self._element_mgr.has_elements()

    def get_all_elements_raw(self) -> List["Element"]:
        """Return all elements without applying exclusions."""
        return list(self._element_mgr.get_all_elements())

    def get_elements_by_type(self, element_type: str) -> List[Any]:
        """Return the elements for a specific backing collection (e.g. 'words')."""
        return list(self._element_mgr.get_elements(element_type))

    def add_element(self, element: Any, element_type: str = "words") -> bool:
        """Add an element to the backing collection."""
        return bool(self._element_mgr.add_element(element, element_type))

    def _infer_element_type(self, element: Any, default: str = "words") -> str:
        """Best-effort inference of element collection name for an object."""
        element_type = getattr(element, "object_type", None)
        if element_type is None and isinstance(element, dict):
            element_type = element.get("object_type")

        if isinstance(element_type, str):
            normalized = element_type.lower()
            if normalized == "word":
                return "words"
            if normalized == "char":
                return "chars"
            if normalized == "rect":
                return "rects"
            if normalized == "line":
                return "lines"
            if normalized == "region":
                return "regions"
            if normalized.endswith("s"):
                return normalized
        return default

    def remove_element(self, element: Any, element_type: Optional[str] = None) -> bool:
        """Remove an element from the backing collection."""
        target_type = element_type or self._infer_element_type(element)
        return bool(self._element_mgr.remove_element(element, target_type))

    def remove_elements_by_source(self, element_type: str, source: str) -> int:
        """Remove all elements of a given type whose source matches."""
        return int(self._element_mgr.remove_elements_by_source(element_type, source))

    def _ocr_element_manager(self) -> ElementManager:
        return self._element_mgr

    def _ocr_scope(self) -> str:
        return "page"

    def _ocr_render_kwargs(self, *, apply_exclusions: bool = True) -> Dict[str, Any]:
        return {"apply_exclusions": apply_exclusions}

    def apply_ocr(
        self,
        engine: Optional[str] = None,
        *,
        options: Optional[Any] = None,
        languages: Optional[List[str]] = None,
        min_confidence: Optional[float] = None,
        device: Optional[str] = None,
        resolution: Optional[int] = None,
        detect_only: bool = False,
        apply_exclusions: bool = True,
        replace: bool = True,
        model: Optional[str] = None,
        client: Optional[Any] = None,
        instructions: Optional[str] = None,
        function: Optional[Callable] = None,
        **kwargs,
    ) -> "Page":
        """Apply OCR to the entire page.

        Args:
            engine: OCR engine — ``"easyocr"`` (default), ``"surya"``,
                ``"paddle"``, ``"paddlevl"``, ``"doctr"``, or ``"vlm"``.
                Use ``engine="vlm"`` with ``model=`` and/or ``client=``
                for VLM-based OCR.
            options: Engine-specific option object.
            languages: Language codes, e.g. ``["en", "fr"]``.
            min_confidence: Discard results below this confidence (0–1).
            device: Compute device, e.g. ``"cpu"`` or ``"cuda"``.
            resolution: DPI for the page image sent to the engine.
            detect_only: Detect text regions without recognizing characters.
            apply_exclusions: Mask exclusion zones before OCR.
            replace: Remove existing OCR elements first.
            model: VLM model name — switches to VLM OCR pipeline.
            client: OpenAI-compatible client — switches to VLM OCR pipeline.
            instructions: Additional instructions appended to the VLM prompt.
                Ignored when ``prompt`` is passed directly via ``**kwargs``.
            function: Custom OCR callable that receives a Region and returns text.
            **kwargs: Extra engine-specific parameters.

        Returns:
            Self for chaining.
        """

        params: dict = dict(kwargs)
        if engine is not None:
            params["engine"] = engine
        if options is not None:
            params["options"] = options
        if languages is not None:
            params["languages"] = languages
        if min_confidence is not None:
            params["min_confidence"] = min_confidence
        if device is not None:
            params["device"] = device
        if resolution is not None:
            params["resolution"] = resolution
        if detect_only:
            params["detect_only"] = detect_only
        if not apply_exclusions:
            params["apply_exclusions"] = apply_exclusions

        # VLM OCR path: explicit engine="vlm" or model=/client= provided
        if engine is not None and engine.lower() == "vlm":
            if model is None and client is None:
                from natural_pdf.core.vlm_client import get_default_client

                default_client, default_model = get_default_client()
                if default_client is None:
                    raise ValueError(
                        'apply_ocr(engine="vlm") requires a model= and/or client= '
                        "parameter, or a default client set via "
                        "natural_pdf.set_default_client(). Example:\n"
                        '  page.apply_ocr(engine="vlm", model="gemini-2.5-flash", client=client)'
                    )
            params.pop("engine", None)
            return self._apply_vlm_ocr(
                model=model,
                client=client,
                replace=replace,
                resolution=resolution or 144,
                min_confidence=min_confidence,
                languages=languages,
                instructions=instructions,
            )

        if model is not None or client is not None:
            if engine is not None:
                logger.warning(
                    "apply_ocr: model=/client= switches to VLM OCR; engine=%r is ignored.",
                    engine,
                )
            params.pop("engine", None)
            return self._apply_vlm_ocr(
                model=model,
                client=client,
                replace=replace,
                instructions=instructions,
                **{k: v for k, v in params.items() if k not in ("replace", "model", "client")},
            )

        custom_func = function or params.pop("ocr_function", None)
        if callable(custom_func):
            params.pop("function", None)
            region = self._full_page_region()
            region.apply_ocr(
                replace=replace,
                function=custom_func,
                **{k: v for k, v in params.items() if k not in ("replace",)},
            )
            return self

        self.services.ocr.apply_ocr(self, replace=replace, **params)
        return self

    def _apply_vlm_ocr(
        self,
        *,
        model: Optional[str] = None,
        client: Optional[Any] = None,
        replace: bool = True,
        resolution: int = 144,
        render_kwargs: Optional[Dict[str, Any]] = None,
        max_new_tokens: int = 4096,
        prompt: Optional[str] = None,
        instructions: Optional[str] = None,
        min_confidence: Optional[float] = None,
        languages: Optional[List[str]] = None,
    ) -> "Page":
        """Run VLM-based OCR and create text elements from the results."""
        from natural_pdf.ocr.vlm_ocr import run_vlm_ocr, scale_ocr_results

        # Resolve languages from config chain if not explicitly provided
        if languages is None:
            resolved = resolve_ocr_languages(self, None)
            if resolved:
                languages = resolved

        if replace:
            removed = self.services.ocr.remove_ocr_elements(self)
            if removed:
                logger.info("Removed %d OCR elements before VLM OCR run.", removed)

        results, (img_w, img_h) = run_vlm_ocr(
            self,
            model=model,
            client=client,
            resolution=resolution,
            render_kwargs=render_kwargs,
            max_new_tokens=max_new_tokens,
            prompt=prompt,
            instructions=instructions,
            languages=languages,
        )

        if not results:
            logger.warning("VLM OCR returned no results for page %s.", self.number)
            return self

        page_width = getattr(self, "width", 0) or 0
        page_height = getattr(self, "height", 0) or 0

        scaled = scale_ocr_results(
            results,
            image_width=float(img_w),
            image_height=float(img_h),
            page_width=float(page_width),
            page_height=float(page_height),
        )

        # Filter by min_confidence if requested
        if min_confidence is not None:
            scaled = [r for r in scaled if r.get("confidence", 0) >= min_confidence]

        self.services.ocr.create_text_elements_from_ocr(
            self,
            ocr_results=scaled,
        )

        logger.info("VLM OCR created %d text elements on page %s.", len(scaled), self.number)
        return self

    def extract_ocr_elements(self, *args, **kwargs):
        """Extract OCR results without mutating the page."""

        return self.services.ocr.extract_ocr_elements(self, *args, **kwargs)

    def remove_ocr_elements(self, *args, **kwargs) -> int:
        """Remove OCR-derived elements from the backing element manager."""

        return self.services.ocr.remove_ocr_elements(self, *args, **kwargs)

    def clear_text_layer(self, *args, **kwargs) -> Tuple[int, int]:
        """Clear the underlying word/char layers for this page."""

        return self.services.ocr.clear_text_layer(self, *args, **kwargs)

    def create_text_elements_from_ocr(self, *args, **kwargs):
        """Proxy for ElementManager.create_text_elements_from_ocr."""

        return self.services.ocr.create_text_elements_from_ocr(self, *args, **kwargs)

    def apply_custom_ocr(
        self,
        *,
        ocr_function,
        source_label: str = "custom-ocr",
        replace: bool = True,
        confidence: Optional[float] = None,
        add_to_page: bool = True,
    ) -> "Page":
        """Apply a custom OCR function via the shared OCR service."""

        self.services.ocr.apply_custom_ocr(
            self,
            ocr_function=ocr_function,
            source_label=source_label,
            replace=replace,
            confidence=confidence,
            add_to_page=add_to_page,
        )
        return self

    def iter_regions(self) -> List["Region"]:
        """Return a list of regions currently registered with the page."""
        return list(self._element_mgr.regions)

    def remove_regions_by_source(self, source: str) -> int:
        """Remove all registered regions that match the requested source."""
        return int(self._element_mgr.remove_elements_by_source("regions", source))

    def remove_regions(
        self,
        *,
        source: Optional[str] = None,
        region_type: Optional[str] = None,
        predicate: Optional[Callable[["Region"], bool]] = None,
    ) -> int:
        """
        Remove regions from the page based on optional filters.

        Args:
            source: Match regions whose ``region.source`` equals this string.
            region_type: Match regions whose ``region.region_type`` equals this string.
            predicate: Additional callable that returns True when a region should be removed.

        Returns:
            The number of regions removed.
        """

        def _matches(region: "Region") -> bool:
            if source is not None and getattr(region, "source", None) != source:
                return False
            if region_type is not None and getattr(region, "region_type", None) != region_type:
                return False
            if predicate is not None and not predicate(region):
                return False
            return True

        removed = 0

        # Remove from element manager store (the authoritative source for
        # iter_regions / selector queries).  We must write back via _store.set
        # because the .regions property returns a copy.
        if hasattr(self, "_element_mgr"):
            store = self._element_mgr._element_store()
            current = store.get("regions", [])
            if current:
                filtered = [r for r in current if not _matches(r)]
                mgr_removed = len(current) - len(filtered)
                if mgr_removed:
                    self._element_mgr._store.set("regions", filtered)
                    removed += mgr_removed

        # Remove from detected collection
        detected = self._regions.get("detected", [])
        if detected:
            retained = [region for region in detected if not _matches(region)]
            removed += len(detected) - len(retained)
            self._regions["detected"] = retained

        # Remove from named collection
        named = self._regions.get("named", {})
        if named:
            keys_to_delete = [key for key, region in named.items() if _matches(region)]
            for key in keys_to_delete:
                del named[key]
                removed += 1

        return removed

    def _get_exclusion_regions(self, include_callable=True, debug=False) -> List["Region"]:
        """
        Get all exclusion regions for this page.
        Now handles both region-based and element-based exclusions.
        Assumes self._exclusions contains tuples of (callable/Region/Element, label, method).

        Args:
            include_callable: Whether to evaluate callable exclusion functions
            debug: Enable verbose debug logging for exclusion evaluation

        Returns:
            List of Region objects to exclude, with labels assigned.
        """
        all_exclusions: List[Tuple[Any, Optional[str], str]] = [
            (spec[0], spec[1], spec[2]) if len(spec) == 3 else (spec[0], spec[1], "region")
            for spec in self._exclusions
        ]

        if hasattr(self, "_parent") and self._parent and hasattr(self._parent, "_exclusions"):
            existing_labels = {label for _, label, _ in all_exclusions if label}
            for pdf_exclusion in self._parent._exclusions:
                label = pdf_exclusion[1] if len(pdf_exclusion) >= 2 else None
                if label and label in existing_labels:
                    continue
                if len(pdf_exclusion) == 2:
                    all_exclusions.append((pdf_exclusion[0], pdf_exclusion[1], "region"))
                else:
                    all_exclusions.append(cast(Tuple[Any, Optional[str], str], pdf_exclusion))

        if debug:
            print(
                f"\nPage {self.index}: Evaluating {len(all_exclusions)} exclusions ({len(self._exclusions)} page-specific, {len(all_exclusions) - len(self._exclusions)} from PDF)"
            )

        service = resolve_service(self, "exclusion")
        regions = service.evaluate_entries(
            self, all_exclusions, include_callable=include_callable, debug=debug
        )
        if debug:
            print(f"Page {self.index}: Found {len(regions)} valid exclusion regions to apply")

        return regions

    def _filter_elements_by_exclusions(
        self, elements: List["Element"], debug_exclusions: bool = False
    ) -> List["Element"]:
        """
        Filters a list of elements, removing those based on exclusion rules.
        Handles both region-based exclusions (exclude all in area) and
        element-based exclusions (exclude only specific elements).

        Args:
            elements: The list of elements to filter.
            debug_exclusions: Whether to output detailed exclusion debugging info (default: False).

        Returns:
            A new list containing only the elements not excluded.
        """
        # Skip exclusion filtering if we're currently computing exclusions
        # This prevents infinite recursion when exclusion callables use find operations
        if self._computing_exclusions:
            return elements

        # Check both page-level and PDF-level exclusions
        has_page_exclusions = bool(self._exclusions)
        has_pdf_exclusions = (
            hasattr(self, "_parent")
            and self._parent
            and hasattr(self._parent, "_exclusions")
            and bool(self._parent._exclusions)
        )

        if not has_page_exclusions and not has_pdf_exclusions:
            if debug_exclusions:
                print(
                    f"Page {self.index}: No exclusions defined, returning all {len(elements)} elements."
                )
            return elements

        # Get all exclusion regions, including evaluating callable functions
        exclusion_regions = self._get_exclusion_regions(
            include_callable=True, debug=debug_exclusions
        )

        # Collect element-based exclusions
        # Use rounded bboxes for comparison (avoids float equality issues)
        # and element ids for direct element exclusions (exact identity)
        def _round_bbox(bbox):
            return tuple(round(v, 2) for v in bbox)

        excluded_element_bboxes = set()  # Rounded bboxes for O(1) lookup
        excluded_element_ids = set()  # Element ids for identity matching

        # Process both page-level and PDF-level exclusions
        all_exclusions = list(self._exclusions) if has_page_exclusions else []
        if has_pdf_exclusions:
            all_exclusions.extend(self._parent._exclusions)

        for exclusion_data in all_exclusions:
            # Handle both old format (2-tuple) and new format (3-tuple)
            if len(exclusion_data) == 2:
                exclusion_item, label = exclusion_data
                method = "region"
            else:
                exclusion_item, label, method = exclusion_data

            # Skip callables (already handled in _get_exclusion_regions)
            if callable(exclusion_item):
                continue

            # Skip regions (already in exclusion_regions)
            if isinstance(exclusion_item, Region):
                continue

            # Handle string selectors for element-based exclusions
            if isinstance(exclusion_item, str) and method == "element":
                selector_str = exclusion_item
                matching_elements = self.find_all(selector_str, apply_exclusions=False)
                elements_iter = getattr(matching_elements, "elements", [])
                for el in cast(Iterable[Any], elements_iter):
                    bbox_vals = extract_bbox(cast(Any, el))
                    if bbox_vals is None:
                        continue
                    excluded_element_bboxes.add(_round_bbox(bbox_vals))
                    excluded_element_ids.add(id(el))
                    if debug_exclusions:
                        print(
                            f"  - Added element exclusion from selector '{selector_str}': {bbox_vals}"
                        )

            # Handle element-based exclusions
            elif method == "element":
                bbox = extract_bbox(cast(Any, exclusion_item))
                if bbox is not None:
                    excluded_element_bboxes.add(_round_bbox(bbox))
                    excluded_element_ids.add(id(exclusion_item))
                    if debug_exclusions:
                        print(f"  - Added element exclusion with bbox {bbox}: {exclusion_item}")
                else:
                    logger.warning(
                        f"Page {self.index}: Skipping element exclusion without bounding box: {exclusion_item}"
                    )

        if debug_exclusions:
            print(
                f"Page {self.index}: Applying {len(exclusion_regions)} region exclusions "
                f"and {len(excluded_element_bboxes)} element exclusions to {len(elements)} elements."
            )

        filtered_elements = []
        region_excluded_count = 0
        element_excluded_count = 0

        for element in elements:
            exclude = False

            # Check element-based exclusions first (faster)
            # Try identity match first, then rounded bbox comparison
            if id(element) in excluded_element_ids:
                exclude = True
            else:
                element_bbox = extract_bbox(element)
                if (
                    element_bbox is not None
                    and _round_bbox(element_bbox) in excluded_element_bboxes
                ):
                    exclude = True
            if exclude:
                exclude = True
                element_excluded_count += 1
                if debug_exclusions:
                    print(f"    Element {element} excluded by element-based rule")
            else:
                # Check region-based exclusions
                for region in exclusion_regions:
                    # Use the region's method to check if the element is inside
                    if region._is_element_in_region(element):
                        exclude = True
                        region_excluded_count += 1
                        if debug_exclusions:
                            print(f"    Element {element} excluded by region {region}")
                        break  # No need to check other regions for this element

            if not exclude:
                filtered_elements.append(element)

        if debug_exclusions:
            print(
                f"Page {self.index}: Excluded {region_excluded_count} by regions, "
                f"{element_excluded_count} by elements, keeping {len(filtered_elements)}."
            )

        return filtered_elements

    @contextlib.contextmanager
    def _temporary_text_settings(
        self,
        text_tolerance: Optional[Dict[str, Any]] = None,
        auto_text_tolerance: Optional[Union[bool, Dict[str, Any]]] = None,
    ):
        """
        Temporarily override page-level text tolerance settings and refresh caches.
        """
        overrides: Dict[str, Any] = {}
        if text_tolerance:
            overrides.update(
                {
                    key: value
                    for key, value in dict(text_tolerance).items()
                    if key
                    in {
                        "x_tolerance",
                        "y_tolerance",
                        "x_tolerance_ratio",
                        "y_tolerance_ratio",
                        "keep_blank_chars",
                    }
                }
            )

        auto_override: Optional[bool] = None
        if isinstance(auto_text_tolerance, dict):
            overrides.update(dict(auto_text_tolerance))
            auto_override = True
        else:
            auto_override = auto_text_tolerance

        if auto_override is not None:
            overrides["auto_text_tolerance"] = bool(auto_override)

        if not overrides:
            yield False
            return

        sentinel = object()
        page_config = self._config
        previous_values: Dict[str, Any] = {key: page_config.get(key, sentinel) for key in overrides}
        cache_invalidated = False
        try:
            for key, value in overrides.items():
                if value is None:
                    if key in page_config:
                        del page_config[key]
                        cache_invalidated = True
                elif page_config.get(key) != value:
                    page_config[key] = value
                    cache_invalidated = True

            if cache_invalidated:
                self._element_mgr.invalidate_cache()
            yield cache_invalidated
        finally:
            if not cache_invalidated:
                return

            restore_invalidated = False
            for key, prior in previous_values.items():
                if prior is sentinel:
                    if key in page_config:
                        del page_config[key]
                        restore_invalidated = True
                elif page_config.get(key) != prior:
                    page_config[key] = prior
                    restore_invalidated = True

            if restore_invalidated:
                self._element_mgr.invalidate_cache()

    def _get_element_pool(self, element_type: str) -> List[Any]:
        """Return the element pool for a given selector type."""
        et = element_type.lower()
        if et in ("text", "word"):
            return self._element_mgr.words
        elif et == "char":
            return self._element_mgr.chars
        elif et in ("rect", "rectangle"):
            return self._element_mgr.rects
        elif et == "line":
            return self._element_mgr.lines
        elif et == "image":
            return self._element_mgr.images
        elif et == "region":
            return self._element_mgr.regions
        else:
            return self._element_mgr.get_all_elements()

    def _apply_selector(self, selector_obj: Dict[str, Any], **kwargs: Any) -> "ElementCollection":
        """
        Apply selector to page elements.
        Exclusions are now handled by the calling methods (find, find_all) if requested.

        Args:
            selector_obj: Parsed selector dictionary (single or compound OR selector)
            **kwargs: Additional filter parameters including 'regex' and 'case'

        Returns:
            ElementCollection of matching elements (unfiltered by exclusions)
        """
        from natural_pdf.selectors.parser import _calculate_aggregates, build_execution_plan

        selector_kwargs = dict(kwargs)
        selector_kwargs.setdefault("selector_context", self)

        def _apply_relational(relational_pseudos, elements):
            if not relational_pseudos:
                return elements
            return _apply_relational_post_pseudos(
                self,
                {"relational_pseudos": relational_pseudos},
                elements,
                selector_kwargs,
            )

        def _apply_post(post_pseudos, elements):
            if not post_pseudos:
                return elements
            return _apply_relational_post_pseudos(
                self,
                {"post_pseudos": post_pseudos},
                elements,
                selector_kwargs,
            )

        # Handle compound OR selectors — evaluate each sub-selector against its
        # natural element pool and union results for better performance.
        if selector_obj.get("type") == "or":
            seen_ids: set = set()
            matching_elements: List[Any] = []
            all_post: List[Dict[str, Any]] = []
            all_relational: List[Dict[str, Any]] = []
            branches_with_post = 0

            for sub_selector in selector_obj.get("selectors", []):
                sub_type = sub_selector.get("type", "any").lower()
                pool = self._get_element_pool(sub_type)

                # Calculate aggregates for this sub-selector if needed
                sub_aggregates: Dict[str, Any] = {}
                has_agg = any(
                    isinstance(attr.get("value"), dict) and attr["value"].get("type") == "aggregate"
                    for attr in sub_selector.get("attributes", [])
                )
                if has_agg:
                    sub_aggregates = _calculate_aggregates(pool, sub_selector)

                sub_filter, sub_post, sub_relational = build_execution_plan(
                    sub_selector, aggregates=sub_aggregates, **selector_kwargs
                )
                if sub_post:
                    branches_with_post += 1
                all_post.extend(sub_post)
                all_relational.extend(sub_relational)

                for el in pool:
                    el_id = id(el)
                    if el_id not in seen_ids and sub_filter(el):
                        seen_ids.add(el_id)
                        matching_elements.append(el)

            if branches_with_post > 1:
                names = [p["name"] for p in all_post]
                logger.warning(
                    "Multiple OR branches have post-pseudos (%s). "
                    "These are applied sequentially to the merged result set, "
                    "which may not match expectations. Consider separate queries.",
                    ", ".join(f":{n}" for n in names),
                )

            matching_elements = _apply_relational(all_relational, matching_elements)

            if selector_kwargs.get("reading_order", True):
                if all(hasattr(el, "top") and hasattr(el, "x0") for el in matching_elements):
                    matching_elements.sort(key=lambda el: (el.top, el.x0))
                elif matching_elements:
                    logger.warning(
                        "Cannot sort elements in reading order: Missing required attributes (top, x0)."
                    )

            matching_elements = _apply_post(all_post, matching_elements)
            return ElementCollection(matching_elements)

        # Single selector path
        element_type = selector_obj.get("type", "any").lower()
        elements_to_search = self._get_element_pool(element_type)

        has_aggregates = any(
            isinstance(attr.get("value"), dict) and attr["value"].get("type") == "aggregate"
            for attr in selector_obj.get("attributes", [])
        )

        aggregates: Dict[str, Any] = {}
        if has_aggregates:
            aggregates = _calculate_aggregates(elements_to_search, selector_obj)

        filter_func, post_pseudos, relational_pseudos = build_execution_plan(
            selector_obj, aggregates=aggregates, **selector_kwargs
        )
        matching_elements = [element for element in elements_to_search if filter_func(element)]

        matching_elements = _apply_relational(relational_pseudos, matching_elements)

        if selector_kwargs.get("reading_order", True):
            if all(hasattr(el, "top") and hasattr(el, "x0") for el in matching_elements):
                matching_elements.sort(key=lambda el: (el.top, el.x0))
            elif matching_elements:
                logger.warning(
                    "Cannot sort elements in reading order: Missing required attributes (top, x0)."
                )

        # Handle :closest pseudo-class for fuzzy text matching
        for pseudo in selector_obj.get("pseudo_classes", []):
            name = pseudo.get("name")
            if name != "closest" or pseudo.get("args") is None:
                continue

            search_text = str(pseudo["args"]).strip()
            threshold = 0.0
            if not search_text:
                matching_elements = []
                break

            if "@" in search_text and search_text.count("@") == 1:
                text_part, threshold_part = search_text.rsplit("@", 1)
                try:
                    threshold = float(threshold_part)
                    search_text = text_part.strip()
                except (ValueError, TypeError):
                    pass

            ignore_case = not selector_kwargs.get("case", True)
            scored_elements = []
            for el in matching_elements:
                if not getattr(el, "text", None):
                    continue
                el_text = el.text.strip()
                search_term = search_text
                if ignore_case:
                    el_text = el_text.lower()
                    search_term = search_term.lower()

                ratio = _jaro_winkler_similarity(search_term, el_text)
                contains_match = search_term in el_text
                if ratio >= threshold:
                    scored_elements.append((contains_match, ratio, el))

            scored_elements.sort(key=lambda x: (x[0], x[1]), reverse=True)
            matching_elements = [entry[2] for entry in scored_elements]
            break

        matching_elements = _apply_post(post_pseudos, matching_elements)

        return ElementCollection(matching_elements)

    def create_region(self, x0: float, top: float, x1: float, bottom: float) -> Any:
        """
        Create a region on this page with the specified coordinates.

        Args:
            x0: Left x-coordinate
            top: Top y-coordinate
            x1: Right x-coordinate
            bottom: Bottom y-coordinate

        Returns:
            Region object for the specified coordinates
        """
        from natural_pdf.elements.region import Region

        return Region(self, (x0, top, x1, bottom))

    def _full_page_region(self) -> "Region":
        """Convenience helper that returns a Region covering the entire page."""

        return self.create_region(0, 0, self.width, self.height)

    def region(
        self,
        left: Optional[float] = None,
        top: Optional[float] = None,
        right: Optional[float] = None,
        bottom: Optional[float] = None,
        width: Union[str, float, None] = None,
        height: Optional[float] = None,
    ) -> Any:
        """
        Create a region on this page with more intuitive named parameters,
        allowing definition by coordinates or by coordinate + dimension.

        Args:
            left: Left x-coordinate (default: 0 if width not used).
            top: Top y-coordinate (default: 0 if height not used).
            right: Right x-coordinate (default: page width if width not used).
            bottom: Bottom y-coordinate (default: page height if height not used).
            width: Width definition. Can be:
                    - Numeric: The width of the region in points. Cannot be used with both left and right.
                    - String 'full': Sets region width to full page width (overrides left/right).
                    - String 'element' or None (default): Uses provided/calculated left/right,
                        defaulting to page width if neither are specified.
            height: Numeric height of the region. Cannot be used with both top and bottom.

        Returns:
            Region object for the specified coordinates

        Raises:
            ValueError: If conflicting arguments are provided (e.g., top, bottom, and height)
                        or if width is an invalid string.

        Examples:
            >>> page.region(top=100, height=50)  # Region from y=100 to y=150, default width
            >>> page.region(left=50, width=100)   # Region from x=50 to x=150, default height
            >>> page.region(bottom=500, height=50) # Region from y=450 to y=500
            >>> page.region(right=200, width=50)  # Region from x=150 to x=200
            >>> page.region(top=100, bottom=200, width="full") # Explicit full width
        """
        # Percentage support – convert strings like "30%" to absolute values
        # based on page dimensions.  X-axis params (left, right, width) use
        # page.width; Y-axis params (top, bottom, height) use page.height.

        def _pct_to_abs(val, axis: str):
            if isinstance(val, str) and val.strip().endswith("%"):
                try:
                    pct = float(val.strip()[:-1]) / 100.0
                except ValueError:
                    return val  # leave unchanged if not a number
                return pct * (self.width if axis == "x" else self.height)
            return val

        left = _pct_to_abs(left, "x")
        right = _pct_to_abs(right, "x")
        width = _pct_to_abs(width, "x")
        top = _pct_to_abs(top, "y")
        bottom = _pct_to_abs(bottom, "y")
        height = _pct_to_abs(height, "y")

        is_width_numeric = isinstance(width, (int, float))
        is_width_string = isinstance(width, str)
        width_mode = "element"  # Default mode

        if height is not None and top is not None and bottom is not None:
            raise ValueError("Cannot specify top, bottom, and height simultaneously.")
        if is_width_numeric and left is not None and right is not None:
            raise ValueError("Cannot specify left, right, and a numeric width simultaneously.")
        if is_width_string:
            width_lower = width.lower()
            if width_lower not in ["full", "element"]:
                raise ValueError("String width argument must be 'full' or 'element'.")
            width_mode = width_lower

        final_top = top
        final_bottom = bottom
        final_left = left
        final_right = right

        # Height calculations
        if height is not None:
            if top is not None:
                final_bottom = top + height
            elif bottom is not None:
                final_top = bottom - height
            else:  # Neither top nor bottom provided, default top to 0
                final_top = 0
                final_bottom = height

        # Width calculations (numeric only)
        if is_width_numeric:
            if left is not None:
                final_right = left + width
            elif right is not None:
                final_left = right - width
            else:  # Neither left nor right provided, default left to 0
                final_left = 0
                final_right = width

        # Only default coordinates if they weren't set by dimension calculation
        if final_top is None:
            final_top = 0.0
        if final_bottom is None:
            # Check if bottom should have been set by height calc
            if height is None or top is None:
                final_bottom = float(self.height)

        if final_left is None:
            final_left = 0.0
        if final_right is None:
            # Check if right should have been set by width calc
            if not is_width_numeric or left is None:
                final_right = float(self.width)

        if final_top is None or final_bottom is None or final_left is None or final_right is None:
            raise ValueError("Unable to resolve region coordinates.")

        final_left = float(final_left)
        final_top = float(final_top)
        final_right = float(final_right)
        final_bottom = float(final_bottom)

        if width_mode == "full":
            # Override left/right if mode is full
            final_left = 0
            final_right = self.width

        # Ensure coordinates are within page bounds (clamp)
        final_left = max(0.0, final_left)
        final_top = max(0.0, final_top)
        final_right = min(float(self.width), final_right)
        final_bottom = min(float(self.height), final_bottom)

        # Ensure valid box (x0<=x1, top<=bottom)
        if final_left > final_right:
            logger.warning(f"Calculated left ({final_left}) > right ({final_right}). Swapping.")
            final_left, final_right = final_right, final_left
        if final_top > final_bottom:
            logger.warning(f"Calculated top ({final_top}) > bottom ({final_bottom}). Swapping.")
            final_top, final_bottom = final_bottom, final_top

        from natural_pdf.elements.region import Region

        region = Region(self, (final_left, final_top, final_right, final_bottom))
        return region

    def get_elements(
        self, apply_exclusions=True, debug_exclusions: bool = False
    ) -> List["Element"]:
        """
        Get all elements on this page.

        Args:
            apply_exclusions: Whether to apply exclusion regions (default: True).
            debug_exclusions: Whether to output detailed exclusion debugging info (default: False).

        Returns:
            List of all elements on the page, potentially filtered by exclusions.
        """
        # Get all elements from the element manager
        all_elements = self.get_all_elements_raw()

        # Apply exclusions if requested
        if apply_exclusions:
            return self._filter_elements_by_exclusions(
                all_elements, debug_exclusions=debug_exclusions
            )
        else:
            if debug_exclusions:
                print(
                    f"Page {self.index}: get_elements returning all {len(all_elements)} elements (exclusions not applied)."
                )
            return all_elements

    def filter_elements(
        self, elements: List["Element"], selector: str, **kwargs
    ) -> List["Element"]:
        """
        Filter a list of elements based on a selector.

        Args:
            elements: List of elements to filter
            selector: CSS-like selector string
            **kwargs: Additional filter parameters

        Returns:
            List of elements that match the selector
        """
        from natural_pdf.selectors.parser import parse_selector, selector_to_filter_func

        # Parse the selector
        selector_obj = parse_selector(selector)

        # Create filter function from selector
        filter_func = selector_to_filter_func(selector_obj, **kwargs)

        # Apply the filter to the elements
        matching_elements = [element for element in elements if filter_func(element)]

        # Sort elements in reading order if requested
        if kwargs.get("reading_order", True):
            if all(hasattr(el, "top") and hasattr(el, "x0") for el in matching_elements):
                matching_elements.sort(key=lambda el: (el.top, el.x0))
            else:
                logger.warning(
                    "Cannot sort elements in reading order: Missing required attributes (top, x0)."
                )

        return matching_elements

    def until(
        self,
        selector: str,
        include_endpoint: bool = True,
        *,
        text: Optional[Union[str, Sequence[str]]] = None,
        apply_exclusions: bool = True,
        regex: bool = False,
        case: bool = True,
        text_tolerance: Optional[Dict[str, Any]] = None,
        auto_text_tolerance: Optional[Union[bool, Dict[str, Any]]] = None,
        reading_order: bool = True,
    ) -> Any:
        """
        Select content from the top of the page until matching selector.

        Args:
            selector: CSS-like selector string
            include_endpoint: Whether to include the endpoint element in the region
            **kwargs: Additional selection parameters

        Returns:
            Region object representing the selected content

        Examples:
            >>> page.until('text:contains("Conclusion")')  # Select from top to conclusion
            >>> page.until('line[width>=2]', include_endpoint=False)  # Select up to thick line
        """
        # Find the target element
        target = self.find(
            selector,
            text=text,
            apply_exclusions=apply_exclusions,
            regex=regex,
            case=case,
            text_tolerance=text_tolerance,
            auto_text_tolerance=auto_text_tolerance,
            reading_order=reading_order,
        )
        if not target:
            # If target not found, return a default region (full page)
            from natural_pdf.elements.region import Region

            return Region(self, (0, 0, self.width, self.height))

        # Create a region from the top of the page to the target
        from natural_pdf.elements.region import Region

        # Ensure target has positional attributes before using them
        target_top = getattr(target, "top", 0)
        target_bottom = getattr(target, "bottom", self.height)

        if include_endpoint:
            # Include the target element
            region = Region(self, (0, 0, self.width, target_bottom))
        else:
            # Up to the target element
            region = Region(self, (0, 0, self.width, target_top))

        region.end_element = cast(Element, target)
        return region

    def crop(self, bbox: Optional[Bounds] = None, **kwargs: Any) -> Any:
        """
        Crop the page to the specified bounding box.

        This is a direct wrapper around pdfplumber's crop method.

        Args:
            bbox: Bounding box (x0, top, x1, bottom) or None
            **kwargs: Additional parameters (top, bottom, left, right)

        Returns:
            Cropped page object (pdfplumber.Page)
        """
        # Returns the pdfplumber page object, not a natural-pdf Page
        resolved_bbox: Optional[Tuple[float, float, float, float]]
        if bbox is None:
            resolved_bbox = None
        else:
            bbox_values = extract_bbox(bbox)
            if bbox_values is None:
                raise TypeError("crop expects a 4-tuple bbox or SupportsBBox-compatible object")
            resolved_bbox = bbox_values

        crop_arg: Any = resolved_bbox if resolved_bbox is not None else None
        return self._page.crop(crop_arg, **kwargs)

    def rotate(
        self,
        angle: int = 90,
        direction: Literal["clockwise", "counterclockwise"] = "clockwise",
    ) -> "Page":
        """
        Return a rotated view of this page without mutating the original.

        Rotations are limited to right angles and are applied before pdfplumber
        processes layout, so all downstream extraction (text, tables, etc.)
        sees the content in the new orientation.

        Args:
            angle: Magnitude of rotation in degrees (0/90/180/270).
            direction: Direction of rotation; defaults to clockwise.

        Returns:
            A new Page instance backed by a rotated pdfplumber.Page.
        """
        allowed_angles = {0, 90, 180, 270}
        if angle not in allowed_angles:
            raise ValueError(f"angle must be one of {sorted(allowed_angles)}; got {angle}")

        resolved_angle = angle % 360
        if direction == "counterclockwise" and resolved_angle:
            resolved_angle = (360 - resolved_angle) % 360
        elif direction not in {"clockwise", "counterclockwise"}:
            raise ValueError("direction must be 'clockwise' or 'counterclockwise'")

        if resolved_angle == 0:
            return self

        if not hasattr(self, "_parent") or not hasattr(self._parent, "_pdf"):
            raise RuntimeError("Cannot rotate page: parent PDF is not initialized.")

        # Clone the underlying pdfminer page so we don't mutate shared state.
        cloned_pdfminer_page = copy.copy(self._page.page_obj)
        cloned_attrs = dict(getattr(cloned_pdfminer_page, "attrs", {}))
        cloned_attrs["Rotate"] = resolved_angle
        cloned_pdfminer_page.attrs = cloned_attrs
        cloned_pdfminer_page.rotate = resolved_angle

        # Rebuild a pdfplumber Page with the new rotation baked in.
        doctop = getattr(self._page, "initial_doctop", 0)
        rotated_plumber_page = pdfplumber.page.Page(
            self._parent._pdf,
            cloned_pdfminer_page,
            page_number=self._page.page_number,
            initial_doctop=doctop,
        )

        # Override to_image so rendering matches the rotated layout.
        orig_to_image = rotated_plumber_page.to_image

        def rotated_to_image(
            self_page,
            resolution: Optional[Union[int, float]] = None,
            width: Optional[Union[int, float]] = None,
            height: Optional[Union[int, float]] = None,
            antialias: bool = False,
            force_mediabox: bool = False,
        ):
            from pdfplumber.display import PageImage
            from PIL import Image as PILImage

            base_img = orig_to_image(
                resolution=resolution,
                width=width,
                height=height,
                antialias=antialias,
                force_mediabox=force_mediabox,
            )

            pil_base = base_img.original if hasattr(base_img, "original") else base_img

            if resolved_angle == 90:
                rotated = pil_base.transpose(PILImage.ROTATE_270)  # clockwise
            elif resolved_angle == 180:
                rotated = pil_base.transpose(PILImage.ROTATE_180)
            elif resolved_angle == 270:
                rotated = pil_base.transpose(PILImage.ROTATE_90)  # counterclockwise
            else:
                rotated = pil_base

            return PageImage(
                self_page,
                original=rotated.convert("RGB"),
                resolution=getattr(base_img, "resolution", None)
                or resolution
                or getattr(self_page.pdf, "resolution", 72),
                antialias=antialias,
                force_mediabox=force_mediabox,
            )

        rotated_plumber_page.to_image = MethodType(rotated_to_image, rotated_plumber_page)

        # Wrap in a fresh natural-pdf Page (not added to the parent cache).
        return self.__class__(
            rotated_plumber_page,
            self._parent,
            self._index,
            font_attrs=self._parent._font_attrs,
            load_text=self._load_text,
            context=getattr(self, "_context", None),
        )

    def to_markdown(
        self,
        *,
        model: Optional[str] = None,
        client: Optional[Any] = None,
        resolution: int = 144,
        render_kwargs: Optional[Dict[str, Any]] = None,
        max_new_tokens: int = 4096,
        prompt: Optional[str] = None,
    ) -> str:
        """Convert this page to Markdown using a VLM.

        Falls back to ``extract_text()`` when no model is configured.

        Args:
            model: HuggingFace model ID or remote model name.
            client: OpenAI-compatible client for remote inference.
            resolution: DPI for rendering the page image.
            render_kwargs: Extra kwargs for ``render()``.
            max_new_tokens: Maximum tokens for the VLM to generate.
            prompt: Custom prompt override.

        Returns:
            Markdown string.
        """
        return self.services.conversion.to_markdown(
            self,
            model=model,
            client=client,
            resolution=resolution,
            render_kwargs=render_kwargs,
            max_new_tokens=max_new_tokens,
            prompt=prompt,
        )

    @overload
    def extract_text(
        self,
        preserve_whitespace: bool = ...,
        preserve_line_breaks: bool = ...,
        use_exclusions: bool = ...,
        debug_exclusions: bool = ...,
        content_filter: Any = ...,
        *,
        layout: bool = ...,
        x_density: Optional[float] = ...,
        y_density: Optional[float] = ...,
        x_tolerance: Optional[float] = ...,
        y_tolerance: Optional[float] = ...,
        line_dir: Optional[str] = ...,
        char_dir: Optional[str] = ...,
        strip_final: bool = ...,
        strip_empty: bool = ...,
        bidi: bool = ...,
        return_textmap: Literal[False] = ...,
    ) -> str: ...

    @overload
    def extract_text(
        self,
        preserve_whitespace: bool = ...,
        preserve_line_breaks: bool = ...,
        use_exclusions: bool = ...,
        debug_exclusions: bool = ...,
        content_filter: Any = ...,
        *,
        layout: bool = ...,
        x_density: Optional[float] = ...,
        y_density: Optional[float] = ...,
        x_tolerance: Optional[float] = ...,
        y_tolerance: Optional[float] = ...,
        line_dir: Optional[str] = ...,
        char_dir: Optional[str] = ...,
        strip_final: bool = ...,
        strip_empty: bool = ...,
        bidi: bool = ...,
        return_textmap: Literal[True] = ...,
    ) -> Tuple[str, Any]: ...

    def extract_text(
        self,
        preserve_whitespace: bool = True,
        preserve_line_breaks: bool = True,
        use_exclusions: bool = True,
        debug_exclusions: bool = False,
        content_filter=None,
        *,
        layout: bool = False,
        x_density: Optional[float] = None,
        y_density: Optional[float] = None,
        x_tolerance: Optional[float] = None,
        y_tolerance: Optional[float] = None,
        line_dir: Optional[str] = None,
        char_dir: Optional[str] = None,
        strip_final: bool = False,
        strip_empty: bool = False,
        bidi: bool = True,
        return_textmap: bool = False,
    ) -> Union[str, Tuple[str, Any]]:
        """
        Extract text from this page, respecting exclusions and using pdfplumber's
        layout engine (chars_to_textmap) if layout arguments are provided or default.

        Args:
            preserve_line_breaks: When False, collapse newlines into spaces for a flattened string.
            use_exclusions: Whether to apply exclusion regions (default: True).
                            Note: Filtering logic is now always applied if exclusions exist.
            debug_exclusions: Whether to output detailed exclusion debugging info (default: False).
            content_filter: Optional content filter to exclude specific text patterns. Can be:
                - A regex pattern string (characters matching the pattern are EXCLUDED)
                - A callable that takes text and returns True to KEEP the character
                - A list of regex patterns (characters matching ANY pattern are EXCLUDED)
            layout: Whether to enable layout-aware spacing (default: False).
            x_density: Horizontal character density override.
            y_density: Vertical line density override.
            x_tolerance: Horizontal clustering tolerance.
            y_tolerance: Vertical clustering tolerance.
            line_dir: Line reading direction override.
            char_dir: Character reading direction override.
            strip_final: When True, strip trailing whitespace from the combined text.
            strip_empty: When True, drop entirely blank lines from the output.
            bidi: Whether to apply bidi reordering when RTL text is detected (default: True).

        Returns:
            Extracted text as string, potentially with layout-based spacing.
        """
        logger.debug(
            "Page %s: extract_text called with layout=%s, x_density=%s, y_density=%s",
            self.number,
            layout,
            x_density,
            y_density,
        )
        debug = debug_exclusions

        # 1. Get Word Elements (triggers load_elements if needed)
        word_elements = self.words
        if not word_elements:
            # Even with no native words, alt_text regions may contribute text
            alt_char_dicts = []
            for region in self.iter_regions():
                if getattr(region, "alt_text", None) is not None:
                    alt_char_dicts.append(_create_alt_text_char_dict(region))
            if not alt_char_dicts:
                logger.debug(f"Page {self.number}: No word elements found.")
                if return_textmap:
                    return "", None
                return ""
            # Generate text from alt_text regions only
            page_bbox = (0, 0, self.width, self.height)
            result = generate_text_layout(
                char_dicts=alt_char_dicts,
                layout_context_bbox=page_bbox,
                user_kwargs={"layout": layout},
            )
            if return_textmap:
                return result, None
            return result

        # 2. Apply element-based exclusions if enabled
        # Check both page-level and PDF-level exclusions
        has_exclusions = bool(self._exclusions) or (
            hasattr(self, "_parent")
            and self._parent
            and hasattr(self._parent, "_exclusions")
            and self._parent._exclusions
        )
        if use_exclusions and has_exclusions:
            # Filter word elements through _filter_elements_by_exclusions
            # This handles both element-based and region-based exclusions
            word_elements = self._filter_elements_by_exclusions(
                word_elements, debug_exclusions=debug
            )
            if debug:
                logger.debug(
                    f"Page {self.number}: {len(word_elements)} words remaining after exclusion filtering."
                )

        # 3. Get region-based exclusions for spatial filtering
        exclusion_regions = []
        apply_exclusions_flag = use_exclusions
        if apply_exclusions_flag and has_exclusions:
            exclusion_regions = self._get_exclusion_regions(include_callable=True, debug=debug)
            if debug:
                logger.debug(
                    f"Page {self.number}: Found {len(exclusion_regions)} region exclusions for spatial filtering."
                )
        elif debug:
            logger.debug(f"Page {self.number}: Not applying exclusions.")

        # 4. Collect All Character Dictionaries from remaining Word Elements
        all_char_dicts = []
        for word in word_elements:
            all_char_dicts.extend(getattr(word, "_char_dicts", []))

        # 4b. Inject alt_text from regions on this page
        for region in self.iter_regions():
            if region.alt_text is not None:
                all_char_dicts.append(_create_alt_text_char_dict(region))

        # 5. Spatially Filter Characters (only by regions, elements already filtered above)
        filtered_chars = filter_chars_spatially(
            char_dicts=all_char_dicts,
            exclusion_regions=exclusion_regions,
            target_region=None,  # No target region for full page extraction
            debug=debug,
        )

        # 5. Generate Text Layout using Utility
        # Pass page bbox as layout context
        page_bbox = (0, 0, self.width, self.height)
        # Merge PDF-level default tolerances if caller did not override
        merged_kwargs = {
            "layout": layout,
            "x_density": x_density,
            "y_density": y_density,
            "x_tolerance": x_tolerance,
            "y_tolerance": y_tolerance,
            "line_dir": line_dir,
            "char_dir": char_dir,
        }
        merged_kwargs = {
            key: value
            for key, value in merged_kwargs.items()
            if value is not None or key == "layout"
        }
        tol_keys = ["x_tolerance", "x_tolerance_ratio", "y_tolerance"]
        for k in tol_keys:
            if k not in merged_kwargs:
                if k in self._config:
                    merged_kwargs[k] = self._config[k]
                elif hasattr(self._parent, "_config") and k in self._parent._config:
                    merged_kwargs[k] = self._parent._config[k]

        # Add content_filter to kwargs if provided
        if content_filter is not None:
            merged_kwargs["content_filter"] = content_filter

        textmap_obj = None
        if return_textmap:
            result, textmap_obj = generate_text_layout(
                char_dicts=filtered_chars,
                layout_context_bbox=page_bbox,
                user_kwargs=merged_kwargs,
                return_textmap=True,
            )
        else:
            result = generate_text_layout(
                char_dicts=filtered_chars,
                layout_context_bbox=page_bbox,
                user_kwargs=merged_kwargs,
            )

        if bidi and result:
            result = apply_bidi_processing(result)

        if strip_empty and result:
            result = "\n".join(line for line in result.splitlines() if line.strip())

        if strip_final and result:
            result = "\n".join(line.rstrip() for line in result.splitlines()).strip()

        if result and not preserve_line_breaks:
            normalized = result.replace("\r\n", "\n").replace("\r", "\n")
            if preserve_whitespace:
                result = re.sub(r"\s*\n\s*", " ", normalized)
            else:
                result = " ".join(normalized.split())

        logger.debug(f"Page {self.number}: extract_text finished, result length: {len(result)}.")
        if return_textmap:
            return result, textmap_obj
        return result

    def extract_table(
        self,
        method: Optional[str] = None,
        table_settings: Optional[dict] = None,
        use_ocr: bool = False,
        ocr_config: Optional[dict] = None,
        text_options: Optional[Dict[str, Any]] = None,
        cell_extraction_func: Optional[Callable[[Any], Optional[str]]] = None,
        show_progress: bool = False,
        content_filter: Optional[Union[str, Sequence[str], Callable[[str], bool]]] = None,
        apply_exclusions: bool = True,
        verticals: Optional[Sequence[float]] = None,
        horizontals: Optional[Sequence[float]] = None,
        outer: bool = False,
        structure_engine: Optional[str] = None,
    ) -> TableResult:
        """Call the table service with the canonical extract_table signature."""

        region = self._full_page_region()
        return self.services.table.extract_table(
            region,
            method=method,
            table_settings=table_settings,
            use_ocr=use_ocr,
            ocr_config=ocr_config,
            text_options=text_options,
            cell_extraction_func=cell_extraction_func,
            show_progress=show_progress,
            content_filter=content_filter,
            apply_exclusions=apply_exclusions,
            verticals=verticals,
            horizontals=horizontals,
            outer=outer,
            structure_engine=structure_engine,
        )

    def extract_tables(
        self,
        method: Optional[str] = None,
        table_settings: Optional[dict] = None,
    ) -> "List[TableResult]":
        """Call the table service to extract every table for the host."""

        region = self._full_page_region()
        return self.services.table.extract_tables(
            region,
            method=method,
            table_settings=table_settings,
        )

    def _load_elements(self):
        """Load all elements from the page via ElementManager."""
        self._element_mgr.load_elements()

    @property
    def chars(self) -> List[Any]:
        """Get all character elements on this page."""
        return self._element_mgr.chars

    @property
    def words(self) -> List[Any]:
        """Get all word elements on this page."""
        return self._element_mgr.words

    @property
    def rects(self) -> List[Any]:
        """Get all rectangle elements on this page."""
        return self._element_mgr.rects

    @property
    def lines(self) -> List[Any]:
        """Get all line elements on this page."""
        return self._element_mgr.lines

    def add_highlight(
        self,
        bbox: Optional[Tuple[float, float, float, float]] = None,
        color: Optional[Union[Tuple, str]] = None,
        label: Optional[str] = None,
        use_color_cycling: bool = False,
        element: Optional[Any] = None,
        annotate: Optional[List[str]] = None,
        existing: str = "append",
    ) -> "Page":
        """
        Add a highlight to a bounding box or the entire page.
        Delegates to the central HighlightingService.

        Args:
            bbox: Bounding box (x0, top, x1, bottom). If None, highlight entire page.
            color: RGBA color tuple/string for the highlight.
            label: Optional label for the highlight.
            use_color_cycling: If True and no label/color, use next cycle color.
            element: Optional original element being highlighted (for attribute extraction).
            annotate: List of attribute names from 'element' to display.
            existing: How to handle existing highlights ('append' or 'replace').

        Returns:
            Self for method chaining.
        """
        target_bbox = bbox if bbox is not None else (0, 0, self.width, self.height)
        self._highlighter.add(
            page_index=self.index,
            bbox=target_bbox,
            color=color,
            label=label,
            use_color_cycling=use_color_cycling,
            element=element,
            annotate=annotate,
            existing=existing,
        )
        return self

    def add_highlight_polygon(
        self,
        polygon: List[Tuple[float, float]],
        color: Optional[Union[Tuple, str]] = None,
        label: Optional[str] = None,
        use_color_cycling: bool = False,
        element: Optional[Any] = None,
        annotate: Optional[List[str]] = None,
        existing: str = "append",
    ) -> "Page":
        """
        Highlight a polygon shape on the page.
        Delegates to the central HighlightingService.

        Args:
            polygon: List of (x, y) points defining the polygon.
            color: RGBA color tuple/string for the highlight.
            label: Optional label for the highlight.
            use_color_cycling: If True and no label/color, use next cycle color.
            element: Optional original element being highlighted (for attribute extraction).
            annotate: List of attribute names from 'element' to display.
            existing: How to handle existing highlights ('append' or 'replace').

        Returns:
            Self for method chaining.
        """
        self._highlighter.add_polygon(
            page_index=self.index,
            polygon=polygon,
            color=color,
            label=label,
            use_color_cycling=use_color_cycling,
            element=element,
            annotate=annotate,
            existing=existing,
        )
        return self

    def save_image(
        self,
        filename: str,
        width: Optional[int] = None,
        labels: bool = True,
        legend_position: str = "right",
        render_ocr: bool = False,
        include_highlights: bool = True,  # Allow saving without highlights
        resolution: float = 144,
        **kwargs,
    ) -> "Page":
        """
        Save the page image to a file, rendering highlights via HighlightingService.

        Args:
            filename: Path to save the image to.
            width: Optional width for the output image.
            labels: Whether to include a legend.
            legend_position: Position of the legend.
            render_ocr: Whether to render OCR text.
            include_highlights: Whether to render highlights.
            resolution: Resolution in DPI for base image rendering (default: 144 DPI, equivalent to previous scale=2.0).
            **kwargs: Additional args for rendering.

        Returns:
            Self for method chaining.
        """
        # Use export() to save the image
        if include_highlights:
            self.export(
                path=filename,
                resolution=resolution,
                width=width,
                labels=labels,
                legend_position=legend_position,
                render_ocr=render_ocr,
                **kwargs,
            )
        else:
            # For saving without highlights, use render() and save manually
            img = self.render(resolution=resolution, **kwargs)
            if img:
                # Resize if width is specified
                if width is not None and width > 0 and img.width > 0:
                    aspect_ratio = img.height / img.width
                    height = int(width * aspect_ratio)
                    img = img.resize((width, height), Image.Resampling.LANCZOS)

                # Save the image
                if os.path.dirname(filename):
                    os.makedirs(os.path.dirname(filename), exist_ok=True)
                img.save(filename)

        return self

    def clear_highlights(self) -> "Page":
        """
        Clear all highlights *from this specific page* via HighlightingService.

        Returns:
            Self for method chaining
        """
        self._highlighter.clear_page(self.index)
        return self

    def analyze_text_styles(
        self, options: Optional[TextStyleOptions] = None
    ) -> "ElementCollection":
        """
        Analyze text elements by style, adding attributes directly to elements.

        This method uses TextStyleAnalyzer to process text elements (typically words)
        on the page. It adds the following attributes to each processed element:
        - style_label: A descriptive or numeric label for the style group.
        - style_key: A hashable tuple representing the style properties used for grouping.
        - style_properties: A dictionary containing the extracted style properties.

        Args:
            options: Optional TextStyleOptions to configure the analysis.
                        If None, the analyzer's default options are used.

        Returns:
            ElementCollection containing all processed text elements with added style attributes.
        """
        analyzer = TextStyleAnalyzer()
        processed_elements_collection = analyzer.analyze(self, options=options)

        metadata = getattr(self, "metadata", None)
        summary = None
        if isinstance(metadata, dict):
            summary = metadata.get("text_styles_summary")
        self._text_styles_summary = summary or {}

        return processed_elements_collection

    @property
    def size(self) -> Tuple[float, float]:
        """Get the size of the page in points."""
        return (self._page.width, self._page.height)

    @property
    def layout_analyzer(self) -> "LayoutAnalyzer":
        """Get or create the layout analyzer for this page."""
        service = resolve_service(self, "layout")
        analyzer = service.layout_analyzer(self)
        self._layout_analyzer = analyzer
        return analyzer

    def analyze_layout(
        self,
        engine: Optional[str] = None,
        *,
        options: Optional[Any] = None,  # Typed as Any to avoid circular import of LayoutOptions
        confidence: Optional[float] = None,
        classes: Optional[List[str]] = None,
        exclude_classes: Optional[List[str]] = None,
        device: Optional[str] = None,
        existing: str = "replace",
        model_name: Optional[str] = None,
        client: Optional[Any] = None,
        show_progress: Optional[bool] = None,
    ) -> Any:
        """Delegate layout analysis to the configured layout service."""

        kwargs = dict(
            options=options,
            confidence=confidence,
            classes=classes,
            exclude_classes=exclude_classes,
            device=device,
            existing=existing,
            model_name=model_name,
            client=client,
            show_progress=show_progress,
        )
        if engine is not None:
            kwargs["engine"] = engine

        return self.services.layout.analyze_layout(self, **kwargs)

    detect_layout = analyze_layout

    def clear_detected_layout_regions(self) -> "Page":
        """
        Removes all regions from this page that were added by layout analysis
        (i.e., regions where `source` attribute is 'detected').

        This clears the regions both from the page's internal `_regions['detected']` list
        and from the ElementManager's internal list of regions.

        Returns:
            Self for method chaining.
        """
        removed_count = self.remove_regions(source="detected")
        if removed_count:
            logger.info(f"Page {self.index}: Cleared {removed_count} detected layout regions.")
        else:
            logger.debug(f"Page {self.index}: No detected layout regions to clear.")
        return self

    def get_section_between(
        self,
        start_element=None,
        end_element=None,
        include_boundaries="both",
        orientation="vertical",
    ) -> "Region":
        """
        Get a section between two elements on this page.

        Args:
            start_element: Element marking the start of the section
            end_element: Element marking the end of the section
            include_boundaries: How to include boundary elements: 'start', 'end', 'both', or 'none'
            orientation: 'vertical' (default) or 'horizontal' - determines section direction

        Returns:
            Region representing the section

        Raises:
            ValueError: Propagated from Region.get_section_between for invalid inputs.
        """
        # Create a full-page region to operate within
        page_region = self.create_region(0, 0, self.width, self.height)

        # Delegate to the region's method
        return page_region.get_section_between(
            start_element=start_element,
            end_element=end_element,
            include_boundaries=include_boundaries,
            orientation=orientation,
        )

    def split(self, divider, **kwargs: Any) -> "ElementCollection[Region]":
        """Divide the page into sections based on the provided divider elements."""
        base_region = self.create_region(0, 0, self.width, self.height)
        return base_region.split(divider, **kwargs)

    def get_sections(
        self,
        start_elements: Union[str, Sequence[Element], ElementCollection, None] = None,
        end_elements: Union[str, Sequence[Element], ElementCollection, None] = None,
        include_boundaries: str = "start",
        y_threshold: float = 5.0,
        bounding_box: Optional[Bounds] = None,
        orientation: str = "vertical",
        **kwargs: Any,
    ) -> "ElementCollection[Region]":
        """Delegate section extraction to the Region implementation."""

        if bounding_box is not None:
            x0, top, x1, bottom = bounding_box
            base_region = self.create_region(x0, top, x1, bottom)
        else:
            base_region = self.create_region(0, 0, self.width, self.height)

        return base_region.get_sections(
            start_elements=start_elements,
            end_elements=end_elements,
            include_boundaries=include_boundaries,
            orientation=orientation,
            y_threshold=y_threshold,
            **kwargs,
        )

    def __repr__(self) -> str:
        """String representation of the page."""
        return f"<Page number={self.number} index={self.index}>"

    @property
    def text_style_labels(self) -> List[str]:
        """
        Get a sorted list of unique text style labels found on the page.

        Runs text style analysis with default options if it hasn't been run yet.
        To use custom options, call `analyze_text_styles(options=...)` explicitly first.

        Returns:
            A sorted list of unique style label strings.
        """
        summary: dict[str, Any] = getattr(self, "_text_styles_summary", {})
        if not summary:
            metadata = getattr(self, "metadata", None)
            if isinstance(metadata, dict):
                meta_summary = metadata.get("text_styles_summary")
                if isinstance(meta_summary, dict):
                    summary = meta_summary
                    self._text_styles_summary = summary

        if not summary:
            logger.debug(f"Page {self.number}: Running default text style analysis to get labels.")
            self.analyze_text_styles()
            summary = getattr(self, "_text_styles_summary", {})

        if summary:
            labels = {
                style_info["label"] for style_info in summary.values() if "label" in style_info
            }
            return sorted(labels)

        logger.warning(f"Page {self.number}: Text style summary not available after analysis.")
        return []

    def viewer(
        self,
        # elements_to_render: Optional[List['Element']] = None, # No longer needed, from_page handles it
        # include_source_types: List[str] = ['word', 'line', 'rect', 'region'] # No longer needed
    ) -> Any:
        """
        Creates and returns an interactive ipywidget for exploring elements on this page.

        Uses InteractiveViewerWidget.from_page() to create the viewer.

        Returns:
            An InteractiveViewerWidget instance ready for display in Jupyter.

        Raises:
            ImportError: If required dependencies (ipywidgets) are missing.
            ValueError: If image rendering or data preparation fails within from_page.
        """
        # Lazy import to avoid pulling in ipywidgets/IPython at startup
        from natural_pdf.widgets.viewer import _IPYWIDGETS_AVAILABLE, InteractiveViewerWidget

        if not _IPYWIDGETS_AVAILABLE or InteractiveViewerWidget is None:
            raise ImportError(
                "Interactive viewer requires 'ipywidgets'. "
                'Please install with: pip install "ipywidgets>=7.0.0,<10.0.0"'
            )

        # Pass self (the Page object) to the factory method
        return InteractiveViewerWidget.from_page(self)

    def save_searchable(self, output_path: Union[str, "Path"], dpi: int = 300):
        """
        Saves the PDF page with an OCR text layer, making content searchable.

        Requires optional dependencies. Install with: pip install "natural-pdf[export]"

        Note: OCR must have been applied to the pages beforehand
                (e.g., pdf.apply_ocr()).

        Args:
            output_path: Path to save the searchable PDF.
            dpi: Resolution for rendering and OCR overlay (default 300).
        """
        # Import moved here, assuming it's always available now
        from natural_pdf.exporters.searchable_pdf import create_searchable_pdf

        # Convert pathlib.Path to string if necessary
        output_path_str = str(output_path)

        create_searchable_pdf(self, output_path_str, dpi=dpi)
        logger.info(f"Searchable PDF saved to: {output_path_str}")

    def update_text(
        self,
        transform: Callable[[Any], Optional[str]],
        *,
        selector: str = "text",
        apply_exclusions: bool = False,
        max_workers: Optional[int] = None,
        progress_callback: Optional[Callable[[], None]] = None,
        show_progress: bool = True,
    ) -> "Page":  # Return self for chaining
        self.services.text.update_text(
            self,
            transform=transform,
            selector=selector,
            apply_exclusions=apply_exclusions,
            max_workers=max_workers,
            progress_callback=progress_callback,
            show_progress=show_progress,
        )
        return self

    def update_ocr(
        self,
        transform: Callable[[Any], Optional[str]],
        *,
        apply_exclusions: bool = False,
        max_workers: Optional[int] = None,
        progress_callback: Optional[Callable[[], None]] = None,
        show_progress: bool = True,
    ) -> "Page":
        self.services.text.update_ocr(
            self,
            transform=transform,
            apply_exclusions=apply_exclusions,
            max_workers=max_workers,
            progress_callback=progress_callback,
            show_progress=show_progress,
        )
        return self

    def _get_classification_content(self, model_type: str, **kwargs) -> Union[str, Image.Image]:
        if model_type == "text":
            text_content = self.extract_text(
                layout=False, use_exclusions=False
            )  # Simple join, ignore exclusions for classification
            if not text_content or text_content.isspace():
                raise ValueError("Cannot classify page with 'text' model: No text content found.")
            return _normalize_whitespace(text_content)
        elif model_type == "vision":
            resolution = kwargs.get("resolution", 150)
            # Use render() for clean image without highlights
            img = self.render(resolution=resolution)
            if img is None:
                raise ValueError(
                    "Cannot classify page with 'vision' model: Failed to render image."
                )
            return img
        else:
            raise ValueError(f"Unsupported model_type for classification: {model_type}")

    def _get_metadata_storage(self) -> Dict[str, Any]:
        # Ensure metadata exists
        if not hasattr(self, "metadata") or self.metadata is None:
            self.metadata = {}
        return self.metadata

    @property
    def skew_angle(self) -> Optional[float]:
        """Get the detected skew angle for this page (if calculated)."""
        return self._skew_angle

    def detect_skew_angle(
        self,
        resolution: int = 72,
        grayscale: bool = True,
        force_recalculate: bool = False,
        engine: Optional[str] = None,
        **deskew_kwargs,
    ) -> Optional[float]:
        """Detect the skew angle of this page using the deskew provider.

        Args:
            resolution: DPI resolution for rendering before detection.
            grayscale: Whether to convert to grayscale before detection.
            force_recalculate: Re-detect even if a cached angle exists.
            engine: Engine name — ``"projection"`` (default), ``"hough"``, or ``"standard"``.
            **deskew_kwargs: Extra arguments forwarded to the detection engine.
        """
        if self._skew_angle is not None and not force_recalculate:
            logger.debug(f"Page {self.number}: Returning cached skew angle: {self._skew_angle:.2f}")
            return self._skew_angle

        try:
            angle = run_deskew_detect(
                target=self,
                context=self,
                engine_name=engine,
                resolution=resolution,
                grayscale=grayscale,
                deskew_kwargs=deskew_kwargs,
            )
        except ImportError:
            raise
        except Exception as exc:
            logger.warning(f"Page {self.number}: Skew detection failed: {exc}", exc_info=True)
            self._skew_angle = None
            raise

        self._skew_angle = angle
        return angle

    def deskew(
        self,
        resolution: int = 300,
        angle: Optional[float] = None,
        detection_resolution: int = 72,
        engine: Optional[str] = None,
        **deskew_kwargs,
    ) -> Optional[Image.Image]:
        """
        Creates and returns a deskewed PIL image of the page.

        If `angle` is not provided, it will first try to detect the skew angle
        using `detect_skew_angle` (or use the cached angle if available).

        Args:
            resolution: DPI resolution for the output deskewed image.
            angle: The specific angle (in degrees) to rotate by. If None, detects automatically.
            detection_resolution: DPI resolution used for detection if `angle` is None.
            engine: Engine name — ``"projection"`` (default), ``"hough"``, or ``"standard"``.
            **deskew_kwargs: Additional keyword arguments passed to the detection engine
                                if automatic detection is performed.

        Returns:
            A deskewed PIL.Image.Image object.

        Raises:
            Exception: Any errors raised by the configured deskew provider.
        """
        result = run_deskew_apply(
            target=self,
            context=self,
            engine_name=engine,
            resolution=resolution,
            angle=angle,
            detection_resolution=detection_resolution,
            grayscale=True,
            deskew_kwargs=deskew_kwargs,
        )

        return result.image

    # Unified analysis storage (maps to metadata["analysis"])

    @property
    def analyses(self) -> Dict[str, Any]:
        if not hasattr(self, "metadata") or self.metadata is None:
            self.metadata = {}
        return self.metadata.setdefault("analysis", {})

    @analyses.setter
    def analyses(self, value: Dict[str, Any]) -> None:
        if not hasattr(self, "metadata") or self.metadata is None:
            self.metadata = {}
        self.metadata["analysis"] = value

    def describe(self, **kwargs):
        """
        Describe the page content using the describe service.
        """
        return self.services.describe.describe(self, **kwargs)

    def inspect(self, limit: int = 30, **kwargs):
        """
        Inspect the page content using the describe service.
        """
        collection = self.find_all("*")
        return self.services.describe.inspect(collection, limit=limit, **kwargs)

    def ask(
        self,
        question: Any,  # Typed as Any to avoid circular import of QuestionInput
        min_confidence: float = 0.1,
        model: Optional[str] = None,
        debug: bool = False,
        *,
        client: Any = None,
        using: str = "text",
        engine: Optional[str] = None,
        **kwargs: Any,
    ) -> "StructuredDataResult":
        """Ask a question about the page content.

        Args:
            question: Question string or list of question strings.
            min_confidence: Minimum confidence for extractive QA.
            model: Model name for the QA / VLM engine.
            debug: Enable debug output.
            client: OpenAI-compatible client for LLM-backed QA.
            using: Content mode — ``'text'`` or ``'vision'``.
            engine: Extraction engine — ``None`` (auto), ``'doc_qa'``,
                ``'vlm'``.

        Returns:
            :class:`StructuredDataResult` with an ``answer`` field.
        """

        return self.services.qa.ask(
            self,
            question=question,
            min_confidence=min_confidence,
            model=model,
            debug=debug,
            client=client,
            using=using,
            engine=engine,
            **kwargs,
        )

    def extract(
        self,
        schema: Union[Type[Any], Sequence[str]],  # Type[BaseModel] -> Type[Any]
        client: Any = None,
        analysis_key: str = "structured",
        prompt: Optional[str] = None,
        using: str = "text",
        model: Optional[str] = None,
        engine: Optional[str] = None,
        overwrite: bool = True,
        **kwargs: Any,
    ):
        """Run structured extraction and return the result.

        Args:
            schema: A Pydantic BaseModel class or list of field name strings.
            client: An OpenAI-compatible client instance (required for LLM engine).
            analysis_key: Key to store results under in ``self.analyses``.
            prompt: Custom system prompt for the LLM.
            using: Content mode — ``'text'`` (layout text) or ``'vision'`` (rendered image).
            model: Model identifier passed to the LLM client.
            engine: ``'llm'``, ``'doc_qa'``, or None (auto-detect from client).
            overwrite: Re-run if results already exist for *analysis_key*.
            **kwargs: Extra arguments forwarded to the extraction engine.
                ``citations`` (bool): When True, each field's result includes
                source citations mapping the value back to PDF elements.
                ``confidence``: Per-field confidence scoring. Accepts ``True``
                or ``'range'`` for 0.0–1.0 scale, a ``list`` of categorical
                levels, or a ``dict`` mapping values to descriptions.
                ``instructions`` (str): Domain-specific guidance appended to
                the LLM prompt, affecting all reasoning.

        Returns:
            :class:`StructuredDataResult` with attribute, item, and iteration access:

        .. code-block:: python

            result = page.extract(MySchema, client=client, citations=True)

            result.site                    # "Chicago" (attribute access)
            result["site"].value           # "Chicago" (item access)
            result["site"].citations       # ElementCollection of source elements
            result["site"].citations.show()
            result["site"].confidence      # 0.95 (when confidence= is set)
            result.confidences             # {"site": 0.95, ...}
            result.to_dict()               # {"site": "Chicago", ...}
            result.show()                  # highlight all citations on page
        """
        return self.services.extraction.extract(
            self,
            schema=schema,
            client=client,
            analysis_key=analysis_key,
            prompt=prompt,
            using=using,
            model=model,
            engine=engine,
            overwrite=overwrite,
            **kwargs,
        )

    def extract_structured_data(self, *args, **kwargs):
        """Alias for :meth:`extract`."""
        return self.extract(*args, **kwargs)

    def detect_lines(self, *args, **kwargs):
        return self.services.shapes.detect_lines(self, *args, **kwargs)

    def detect_checkboxes(self, *args, **kwargs):
        return self.services.checkbox.detect_checkboxes(self, *args, **kwargs)

    def annotate_checkboxes(self, resolution: int = 150):
        """Open an interactive widget for manual checkbox annotation.

        Draw rectangles on the page image to mark checkbox locations.
        Call ``get_regions()`` on the returned annotator to retrieve results.

        Args:
            resolution: DPI for rendering the page image.

        Returns:
            CheckboxAnnotator instance.
        """
        from natural_pdf.widgets.checkbox_annotator import CheckboxAnnotator

        annotator = CheckboxAnnotator(self, resolution=resolution)
        annotator.show()
        return annotator

    def guides(self, *args, **kwargs):
        return self.services.guides.guides(self, *args, **kwargs)

    def extracted(
        self,
        analysis_key: Optional[str] = None,
    ) -> Any:
        """Retrieve the stored result from a previous ``.extract()`` call.

        Returns the same :class:`StructuredDataResult` that ``.extract()``
        returned, or ``None`` if the extraction failed.
        """
        return self.services.extraction.extracted(
            self,
            analysis_key=analysis_key,
        )

    def _get_extraction_content(self, using: str = "text", **kwargs) -> Any:
        """Internal helper for ExtractionService to gather page content."""
        _return_textmap = kwargs.pop("_return_textmap", False)

        if using == "text":
            layout = kwargs.pop("layout", True)
            if _return_textmap:
                text, textmap = self.extract_text(layout=layout, return_textmap=True, **kwargs)
                word_elements = list(self.words)
                return (text, textmap, word_elements)
            return self.extract_text(layout=layout, **kwargs)

        if using == "vision":
            resolution = kwargs.pop("resolution", 96)
            kwargs.pop("include_highlights", None)
            kwargs.pop("labels", None)
            return self.render(
                resolution=resolution,
                include_highlights=False,
                labels=False,
                **kwargs,
            )

        raise ValueError(f"Unsupported extraction content mode '{using}'")

    def classify(
        self,
        labels: List[str],
        model: Optional[str] = None,
        using: Optional[str] = None,
        min_confidence: float = 0.0,
        analysis_key: str = "classification",
        multi_label: bool = False,
        **kwargs: Any,
    ):
        """Delegate classification to the classification service and return the result."""

        return self.services.classification.classify(
            self,
            labels=labels,
            model=model,
            using=using,
            min_confidence=min_confidence,
            analysis_key=analysis_key,
            multi_label=multi_label,
            **kwargs,
        )

    def remove_text_layer(self) -> "Page":
        """
        Remove all text elements from this page.

        This removes all text elements (words and characters) from the page,
        effectively clearing the text layer.

        Returns:
            Self for method chaining
        """
        logger.info(f"Page {self.number}: Removing all text elements...")

        removed_words, removed_chars = self._element_mgr.clear_text_layer()

        logger.info(
            f"Page {self.number}: Removed {removed_words} words and {removed_chars} characters"
        )
        return self

    def _apply_rtl_processing_to_text(self, text: str) -> str:
        """Delegate to shared BiDi helper."""
        return apply_bidi_processing(text)

    @property
    def images(self) -> List[Any]:
        """Get all embedded raster images on this page."""
        return self._element_mgr.images

    def highlights(self, show: bool = False) -> "HighlightContext":
        """
        Create a highlight context for accumulating highlights.

        This allows for clean syntax to show multiple highlight groups:

        Example:
            with page.highlights() as h:
                h.add(page.find_all('table'), label='tables', color='blue')
                h.add(page.find_all('text:bold'), label='bold text', color='red')
                h.show()

        Or with automatic display:
            with page.highlights(show=True) as h:
                h.add(page.find_all('table'), label='tables')
                h.add(page.find_all('text:bold'), label='bold')
                # Automatically shows when exiting the context

        Args:
            show: If True, automatically show highlights when exiting context

        Returns:
            HighlightContext for accumulating highlights
        """
        from natural_pdf.core.highlighting_service import HighlightContext

        return HighlightContext(self, show_on_exit=show)
