from __future__ import annotations

import logging
import warnings
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Dict,
    Iterable,
    List,
    Literal,
    Optional,
    Sequence,
    Set,
    Tuple,
    Union,
    cast,
    overload,
)

from pdfplumber.utils.geometry import merge_bboxes  # Import merge_bboxes directly

from natural_pdf.core.capabilities import MultiRegionAnalysisMixin
from natural_pdf.core.context import PDFContext
from natural_pdf.core.crop_utils import resolve_crop_bbox
from natural_pdf.core.exclusion_mixin import ExclusionSpec
from natural_pdf.core.highlighter_utils import resolve_highlighter
from natural_pdf.core.interfaces import SupportsSections
from natural_pdf.core.mixins import ContextResolverMixin
from natural_pdf.core.render_spec import RenderSpec, Visualizable
from natural_pdf.elements.base import extract_bbox
from natural_pdf.elements.element_collection import ElementCollection
from natural_pdf.selectors.host_mixin import SelectorHostMixin

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

# For runtime image manipulation


if TYPE_CHECKING:
    from PIL.Image import Image as PIL_Image  # For type hints

    from natural_pdf.core.highlighting_service import HighlightContext, HighlightingService
    from natural_pdf.core.page import Page
    from natural_pdf.elements.base import Element as PhysicalElement
    from natural_pdf.elements.element_collection import ElementCollection
    from natural_pdf.elements.region import Region as PhysicalRegion

    from .element import FlowElement
    from .flow import Flow

logger = logging.getLogger(__name__)


class FlowRegion(
    SelectorHostMixin,
    ServiceHostMixin,
    SupportsSections,
    Visualizable,
    MultiRegionAnalysisMixin,
    ContextResolverMixin,
):
    """
    Represents a selected area within a Flow, potentially composed of multiple
    physical Region objects (constituent_regions) that might span across
    different original pages or disjoint physical regions defined in the Flow.

    A FlowRegion is the result of a directional operation (e.g., .below(), .above())
    on a FlowElement.
    """

    def __init__(
        self,
        flow: "Flow",
        constituent_regions: List["PhysicalRegion"],
        source_flow_element: Optional["FlowElement"] = None,
        boundary_element_found: Optional[Union["PhysicalElement", "PhysicalRegion"]] = None,
    ):
        """
        Initializes a FlowRegion.

        Args:
            flow: The Flow instance this region belongs to.
            constituent_regions: A list of physical natural_pdf.elements.region.Region
                                 objects that make up this FlowRegion.
            source_flow_element: The FlowElement that created this FlowRegion.
            boundary_element_found: The physical element that stopped an 'until' search,
                                    if applicable.
        """
        self.flow: "Flow" = flow
        self.constituent_regions: List["PhysicalRegion"] = constituent_regions
        context = self._resolve_service_context(flow, constituent_regions)
        self._init_service_host(context)
        self.source_flow_element: Optional["FlowElement"] = source_flow_element
        self.boundary_element_found: Optional[Union["PhysicalElement", "PhysicalRegion"]] = (
            boundary_element_found
        )

        self.start_element: Optional[Union["PhysicalElement", "PhysicalRegion"]] = None
        self.end_element: Optional[Union["PhysicalElement", "PhysicalRegion"]] = None
        self._boundary_exclusions: Optional[str] = None

        # Add attributes for grid building, similar to Region
        self.source: Optional[str] = None
        self.region_type: Optional[str] = None
        self.metadata: Dict[str, Any] = {}

        # Cache for expensive operations
        self._cached_text: Optional[str] = None
        self._cached_elements: Optional["ElementCollection"] = None  # Stringized
        self._cached_bbox: Optional[Tuple[float, float, float, float]] = None
        self._exclusions: List[ExclusionSpec] = []
        self._multi_page_page_warned: bool = False

    @staticmethod
    def _resolve_service_context(
        flow: Optional["Flow"], regions: Sequence["PhysicalRegion"]
    ) -> PDFContext:
        candidates: List[Any] = []
        if flow is not None:
            candidates.append(getattr(flow, "_context", None))
        for region in regions:
            candidates.append(getattr(region, "_context", None))
            page = getattr(region, "page", None)
            if page is not None:
                candidates.append(getattr(page, "_context", None))
                pdf_obj = getattr(page, "pdf", getattr(page, "_parent", None))
                if pdf_obj is not None:
                    candidates.append(getattr(pdf_obj, "_context", None))
        for candidate in candidates:
            if candidate is not None:
                return candidate
        return PDFContext.with_defaults()

    def _qa_context_page_number(self) -> int:
        pages = self.pages
        return pages[0].number if pages else -1

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

    def _qa_segments(self) -> Sequence["PhysicalRegion"]:
        return self.constituent_regions

    def _qa_target_region(self):
        if not self.constituent_regions:
            raise RuntimeError("FlowRegion has no constituent regions for QA.")

        return self.constituent_regions[0]

    def to_region(self):
        if not self.constituent_regions:
            raise RuntimeError("FlowRegion has no constituent regions to convert into a Region.")
        return self.constituent_regions[0]

    def _ocr_element_manager(self):
        if not self.constituent_regions:
            raise RuntimeError("FlowRegion has no regions for OCR operations")
        for region in self.constituent_regions:
            page = getattr(region, "page", None)
            mgr = getattr(page, "_element_mgr", None) if page is not None else None
            if mgr is not None:
                return mgr
        raise RuntimeError("FlowRegion has no pages with OCR element managers")

    def clear_text_layer(self) -> Tuple[int, int]:
        total_chars = 0
        total_words = 0
        seen_pages: Set[int] = set()
        for region in self.constituent_regions:
            page = getattr(region, "page", None)
            if page is None:
                continue
            marker = id(page)
            if marker in seen_pages:
                continue
            seen_pages.add(marker)
            cleared_chars, cleared_words = page.clear_text_layer()
            total_chars += cleared_chars
            total_words += cleared_words
        return total_chars, total_words

    def create_text_elements_from_ocr(
        self,
        ocr_results: Any,
        scale_x: Optional[float] = None,
        scale_y: Optional[float] = None,
        *,
        offset_x: float = 0.0,
        offset_y: float = 0.0,
    ) -> List[Any]:
        if not self.constituent_regions:
            return []
        for region in self.constituent_regions:
            page = getattr(region, "page", None)
            if page is None:
                continue
            creator = getattr(page, "create_text_elements_from_ocr", None)
            if callable(creator):
                created = creator(
                    ocr_results,
                    scale_x=scale_x,
                    scale_y=scale_y,
                    offset_x=offset_x,
                    offset_y=offset_y,
                )
                if isinstance(created, list):
                    return created
                if created is None:
                    return []
                if isinstance(created, Iterable):
                    return list(created)
                return [created]
        return []

    def _iter_ocr_regions(self) -> Iterable[Any]:
        return tuple(self.constituent_regions)

    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,
        **kwargs: Any,
    ) -> "FlowRegion":
        """Apply OCR across all constituent regions.

        Args:
            engine: OCR engine — ``"easyocr"``, ``"surya"``, ``"paddle"``,
                ``"paddlevl"``, ``"doctr"``, or ``"vlm"``.
            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 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.
            **kwargs: Extra engine-specific parameters.

        Returns:
            Self for chaining.
        """
        for region in self.constituent_regions:
            apply_fn = getattr(region, "apply_ocr", None)
            if callable(apply_fn):
                apply_fn(
                    engine=engine,
                    replace=replace,
                    options=options,
                    languages=languages,
                    min_confidence=min_confidence,
                    device=device,
                    resolution=resolution,
                    detect_only=detect_only,
                    apply_exclusions=apply_exclusions,
                    model=model,
                    client=client,
                    instructions=instructions,
                    **kwargs,
                )
        return self

    def extract_ocr_elements(self, *args: Any, **kwargs: Any) -> List[Any]:
        """
        Extract OCR elements from each constituent region and flatten the results.
        """
        extracted: List[Any] = []
        for region in self.constituent_regions:
            extract_fn = getattr(region, "extract_ocr_elements", None)
            if callable(extract_fn):
                result = extract_fn(*args, **kwargs)
                if isinstance(result, list):
                    extracted.extend(result)
                elif result is not None:
                    extracted.append(result)
        return extracted

    def _exclusion_element_manager(self):
        if not self.constituent_regions:
            raise RuntimeError("FlowRegion has no constituent regions for exclusions")
        return self.constituent_regions[0].page._element_mgr

    def _evaluate_exclusion_entries(
        self,
        entries: Sequence[ExclusionSpec],
        include_callable: bool = True,
        debug: bool = False,
    ):
        if not entries:
            return []
        service = resolve_service(self, "exclusion")
        return service.evaluate_entries(self, entries, include_callable, debug)

    def _get_exclusion_regions(
        self,
        include_callable: bool = True,
        debug: bool = False,
    ):
        entries = cast(List[ExclusionSpec], getattr(self, "_exclusions", []))
        local = self._evaluate_exclusion_entries(entries, include_callable, debug)

        aggregated: List["PhysicalRegion"] = []
        for region in self.constituent_regions:
            aggregated.extend(
                region._get_exclusion_regions(include_callable=include_callable, debug=debug)
            )
        return self._dedupe_regions((*local, *aggregated))

    @staticmethod
    def _dedupe_regions(regions: Iterable["PhysicalRegion"]) -> List["PhysicalRegion"]:
        deduped: List["PhysicalRegion"] = []
        seen: set[int] = set()
        for region in regions:
            marker = id(region)
            if marker in seen:
                continue
            seen.add(marker)
            deduped.append(region)
        return deduped

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

        page = getattr(element, "page", None)
        if page is None and self.constituent_regions:
            page = self.constituent_regions[0].page
        if page is None:
            return None

        from natural_pdf.elements.region import (
            Region as PhysicalRegion,  # Local import to avoid cycles
        )

        clamp_bbox = bbox
        matching_regions = [
            region for region in self.constituent_regions if getattr(region, "page", None) is page
        ]
        if matching_regions:
            min_x0 = min(region.x0 for region in matching_regions)
            min_top = min(region.top for region in matching_regions)
            max_x1 = max(region.x1 for region in matching_regions)
            max_bottom = max(region.bottom for region in matching_regions)

            clamp_bbox = (
                max(min_x0, bbox[0]),
                max(min_top, bbox[1]),
                min(max_x1, bbox[2]),
                min(max_bottom, bbox[3]),
            )

            if clamp_bbox[0] >= clamp_bbox[2] or clamp_bbox[1] >= clamp_bbox[3]:
                return None

        return PhysicalRegion(page, clamp_bbox, label=label)

    def _invalidate_exclusion_cache(self) -> None:
        self._cached_text = None
        self._cached_elements = None

    def _iter_exclusion_regions(self) -> Iterable[Any]:
        return tuple(self.constituent_regions)

    @property
    def pages(self) -> Tuple["Page", ...]:
        """Return the distinct pages covered by this flow region."""
        seen: Set[int] = set()
        ordered_pages: List["Page"] = []
        for region in self.constituent_regions:
            page = getattr(region, "page", None)
            if page is None:
                continue
            marker = id(page)
            if marker not in seen:
                seen.add(marker)
                ordered_pages.append(page)
        return tuple(ordered_pages)

    @property
    def page(self) -> "Page":
        """Return the primary page for this region (first page when multi-page)."""
        pages = self.pages
        if len(pages) == 0:
            raise AttributeError("FlowRegion has no associated pages")
        if len(pages) > 1 and not self._multi_page_page_warned:
            warnings.warn(
                "FlowRegion spans multiple pages; returning the first page for .page access. "
                "Use .pages to inspect all pages.",
                UserWarning,
                stacklevel=2,
            )
            self._multi_page_page_warned = True
        return pages[0]

    def get_highlighter(self) -> "HighlightingService":
        """Resolve a highlighting service from the constituent regions."""
        if not self.constituent_regions:
            raise RuntimeError("FlowRegion has no constituent regions to get highlighter from")
        return cast("HighlightingService", resolve_highlighter(self.constituent_regions, self.flow))

    def _get_highlighter(self):
        """Compatibility hook for Visualizable mixin."""
        return self.get_highlighter()

    def _context_resolution_roots(self) -> Iterable[Any]:
        roots: List[Any] = [self]
        roots.extend(self.constituent_regions)
        if self.flow is not None:
            roots.append(self.flow)
        return roots

    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 flow region.

        Args:
            mode: Rendering mode - 'show' includes highlights, 'render' is clean
            color: Color for highlighting this region in show mode
            highlights: Additional highlight groups to show
            crop: Whether to crop to constituent regions
            crop_bbox: Explicit crop bounds
            **kwargs: Additional parameters

        Returns:
            List of RenderSpec objects, one per page with constituent regions
        """
        if not self.constituent_regions:
            return []

        label_prefix = kwargs.pop("label_prefix", None)

        regions_by_page = {}
        for region in self.constituent_regions:
            page = getattr(region, "page", None)
            if page is None:
                continue
            regions_by_page.setdefault(page, []).append(region)

        if not regions_by_page:
            return []

        specs = []
        for page, page_regions in regions_by_page.items():
            spec = RenderSpec(page=page)

            def union_bbox() -> Optional[Tuple[float, float, float, float]]:
                x_coords: List[float] = []
                y_coords: List[float] = []
                for region in page_regions:
                    bbox = getattr(region, "bbox", None)
                    if bbox:
                        x0, y0, x1, y1 = bbox
                        x_coords.extend([x0, x1])
                        y_coords.extend([y0, y1])
                if x_coords and y_coords:
                    return (min(x_coords), min(y_coords), max(x_coords), max(y_coords))
                return None

            spec.crop_bbox = resolve_crop_bbox(
                width=page.width,
                height=page.height,
                crop=crop,
                crop_bbox=crop_bbox,
                content_bbox_fn=union_bbox,
            )

            # Add highlights in show mode
            if mode == "show":
                # Highlight constituent regions
                for i, region in enumerate(page_regions):
                    # Label each part if multiple regions
                    label = None
                    if len(self.constituent_regions) > 1:
                        try:
                            global_idx = self.constituent_regions.index(region)
                        except ValueError:
                            global_idx = i
                        if label_prefix:
                            label = f"{label_prefix}_{global_idx + 1}"
                        else:
                            label = f"FlowPart_{global_idx + 1}"
                    else:
                        label = label_prefix or "FlowRegion"

                    spec.add_highlight(
                        bbox=region.bbox,
                        polygon=region.polygon if region.has_polygon else None,
                        color=color or "fuchsia",
                        label=label,
                    )

                # Add additional highlight groups if provided
                if highlights:
                    for group in highlights:
                        group_elements = group.get("elements", [])
                        group_color = group.get("color", color)
                        group_label = group.get("label")

                        for elem in group_elements:
                            # Only add if element is on this page
                            if hasattr(elem, "page") and elem.page == page:
                                spec.add_highlight(
                                    element=elem, color=group_color, label=group_label
                                )

            specs.append(spec)

        return specs

    @property
    def parts(self) -> List["PhysicalRegion"]:
        """Alias for ``constituent_regions`` — the physical region parts of this FlowRegion."""
        return self.constituent_regions

    def map_parts(self, fn: Callable[["PhysicalRegion"], Any]) -> List[Any]:
        """Apply *fn* to each constituent region and return the results."""
        return [fn(region) for region in self.constituent_regions]

    @property
    def is_empty(self) -> bool:
        """True when this FlowRegion contains no constituent regions."""
        return not self.constituent_regions

    @property
    def bbox(self) -> Optional[Tuple[float, float, float, float]]:
        """
        The bounding box that encloses all constituent regions.

        For single-page FlowRegions this is a true geometric union. For
        multi-page FlowRegions the result is a ``merge_bboxes`` over all
        constituent regions regardless of page — useful for sorting and
        size estimates, but not a physically meaningful rectangle.

        Returns ``None`` only when there are no constituent regions.
        """
        if self._cached_bbox is not None:
            return self._cached_bbox
        if not self.constituent_regions:
            return None

        region_bboxes = [
            region.bbox for region in self.constituent_regions if hasattr(region, "bbox")
        ]
        if not region_bboxes:
            return None

        self._cached_bbox = merge_bboxes(region_bboxes)
        return self._cached_bbox

    def _require_bbox(self) -> Tuple[float, float, float, float]:
        bbox = self.bbox
        if bbox is None:
            raise ValueError("FlowRegion has no bounding box; ensure it has constituent regions")
        return bbox

    @property
    def x0(self) -> float:
        return self._require_bbox()[0]

    @property
    def top(self) -> float:
        return self._require_bbox()[1]

    @property
    def x1(self) -> float:
        return self._require_bbox()[2]

    @property
    def bottom(self) -> float:
        return self._require_bbox()[3]

    @property
    def width(self) -> Optional[float]:
        bbox = self.bbox
        if not bbox:
            return None
        return bbox[2] - bbox[0]

    @property
    def height(self) -> Optional[float]:
        bbox = self.bbox
        if not bbox:
            return None
        return bbox[3] - bbox[1]

    @property
    def has_polygon(self) -> bool:
        return False

    @property
    def polygon(self) -> List[Tuple[float, float]]:
        bbox = self.bbox
        if not bbox:
            return []
        x0, y0, x1, y1 = bbox
        return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]

    def save_pdf(
        self,
        path: str,
        method: str = "crop",
    ) -> "FlowRegion":
        """
        Save this FlowRegion as a PDF. Each constituent region becomes a page.

        Args:
            path: Output file path for the PDF.
            method: 'crop' (default) or 'whiteout'.

        Returns:
            Self for method chaining.

        Raises:
            ValueError: If there are no constituent regions or method is invalid.
            ImportError: If pikepdf is not installed.
        """
        from natural_pdf.exporters.region_pdf import create_region_pdf

        if not self.constituent_regions:
            raise ValueError("Cannot save PDF from a FlowRegion with no constituent regions.")

        regions = [(r.page, r.bbox) for r in self.constituent_regions]
        create_region_pdf(regions, path, method=method)
        return self

    def extract_text(self, apply_exclusions: bool = True, **kwargs) -> str:
        """Concatenate text from constituent regions while preserving flow order."""
        if self._cached_text is not None and apply_exclusions:
            return self._cached_text

        if not self.constituent_regions:
            return ""

        from natural_pdf.elements.element_collection import ElementCollection

        elements = self.elements(apply_exclusions=apply_exclusions)
        # ElementCollection.extract_text handles ordering and layout-specific kwargs.
        extracted = elements.extract_text(**kwargs)

        if not extracted:
            return ""

        if apply_exclusions:
            self._cached_text = extracted
        return extracted

    def elements(self, apply_exclusions: bool = True) -> "ElementCollection":  # Stringized return
        """
        Collects all unique physical elements from all constituent physical regions.

        Args:
            apply_exclusions: Whether to respect PDF exclusion zones within each
                              constituent physical region when gathering elements.

        Returns:
            An ElementCollection containing all unique elements.
        """
        from natural_pdf.elements.element_collection import ElementCollection

        if self._cached_elements is not None and apply_exclusions:  # Simple cache check
            return self._cached_elements

        if not self.constituent_regions:
            return ElementCollection([])

        all_physical_elements: List["PhysicalElement"] = []  # Stringized item type
        seen_elements = (
            set()
        )  # To ensure uniqueness if elements are shared or duplicated by region definitions

        for region in self.constituent_regions:
            # Region.get_elements() returns a list, not ElementCollection
            elements_in_region: List["PhysicalElement"] = region.get_elements(
                apply_exclusions=apply_exclusions
            )
            for elem in elements_in_region:
                if elem not in seen_elements:  # Check for uniqueness based on object identity
                    all_physical_elements.append(elem)
                    seen_elements.add(elem)

        # Basic reading order sort based on original page and coordinates.
        def get_sort_key(phys_elem: "PhysicalElement"):  # Stringized param type
            page_idx = -1
            if hasattr(phys_elem, "page") and hasattr(phys_elem.page, "index"):
                page_idx = phys_elem.page.index
            return (page_idx, phys_elem.top, phys_elem.x0)

        try:
            sorted_physical_elements = sorted(all_physical_elements, key=get_sort_key)
        except AttributeError as exc:
            raise AttributeError(
                "Could not sort elements in FlowRegion; ensure elements expose page, top, and x0"
            ) from exc

        result_collection = ElementCollection(sorted_physical_elements)
        if apply_exclusions:
            self._cached_elements = result_collection
        return result_collection

    def highlight(
        self, label: Optional[str] = None, color: Optional[Union[Tuple, str]] = None, **kwargs
    ) -> Optional["PIL_Image"]:
        """
        Highlights all constituent physical regions on their respective pages.

        Args:
            label: A base label for the highlights. Each constituent region might get an indexed label.
            color: Color for the highlight.
            **kwargs: Additional arguments for the underlying highlight method.

        Returns:
            Image generated by the underlying highlight call, or None if no highlights were added.
        """
        if not self.constituent_regions:
            return None

        base_label = label if label else "FlowRegionPart"
        for i, region in enumerate(self.constituent_regions):
            current_label = (
                f"{base_label}_{i+1}" if len(self.constituent_regions) > 1 else base_label
            )
            region.highlight(label=current_label, color=color, **kwargs)
        return None

    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 flow_region.highlights() as h:
                h.add(flow_region.find_all('table'), label='tables', color='blue')
                h.add(flow_region.find_all('text:bold'), label='bold text', color='red')
                h.show()

        Or with automatic display:
            with flow_region.highlights(show=True) as h:
                h.add(flow_region.find_all('table'), label='tables')
                h.add(flow_region.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)

    def to_images(
        self,
        resolution: float = 150,
        **kwargs,
    ) -> List["PIL_Image"]:
        """
        Generates and returns a list of cropped PIL Images,
        one for each constituent physical region of this FlowRegion.
        """
        if not self.constituent_regions:
            logger.info("FlowRegion.to_images() called on an empty FlowRegion.")
            return []

        cropped_images: List["PIL_Image"] = []
        for region_part in self.constituent_regions:
            # Use render() for clean image without highlights
            img = region_part.render(resolution=resolution, crop=True, **kwargs)
            if img:
                cropped_images.append(img)

        return cropped_images

    def __repr__(self) -> str:
        return (
            f"<FlowRegion constituents={len(self.constituent_regions)}, flow={self.flow}, "
            f"source_bbox={self.source_flow_element.bbox if self.source_flow_element else 'N/A'}>"
        )

    @overload
    def expand(self, amount: float, *, apply_exclusions: bool = True) -> "FlowRegion": ...

    @overload
    def expand(
        self,
        *,
        left: Union[float, bool, str] = 0,
        right: Union[float, bool, str] = 0,
        top: Union[float, bool, str] = 0,
        bottom: Union[float, bool, str] = 0,
        width_factor: float = 1.0,
        height_factor: float = 1.0,
        apply_exclusions: bool = True,
    ) -> "FlowRegion": ...

    def expand(
        self,
        amount: Optional[float] = None,
        *,
        left: Union[float, bool, str] = 0,
        right: Union[float, bool, str] = 0,
        top: Union[float, bool, str] = 0,
        bottom: Union[float, bool, str] = 0,
        width_factor: float = 1.0,
        height_factor: float = 1.0,
        apply_exclusions: bool = True,
    ) -> "FlowRegion":
        """
        Create a new FlowRegion with all constituent regions expanded.

        Args:
            left: Amount to expand left edge (positive value expands leftwards)
            right: Amount to expand right edge (positive value expands rightwards)
            top: Amount to expand top edge (positive value expands upwards)
            bottom: Amount to expand bottom edge (positive value expands downwards)
            width_factor: Factor to multiply width by (applied after absolute expansion)
            height_factor: Factor to multiply height by (applied after absolute expansion)

        Returns:
            New FlowRegion with expanded constituent regions
        """
        if not self.constituent_regions:
            return self._spawn_from_regions([])

        if amount is not None:
            left = right = top = bottom = amount

        expanded_regions = []
        for idx, region in enumerate(self.constituent_regions):
            # Determine which adjustments to apply based on flow arrangement
            apply_left = left
            apply_right = right
            apply_top = top
            apply_bottom = bottom

            if self.flow.arrangement == "vertical":
                # In a vertical flow, only the *first* region should react to `top`
                # and only the *last* region should react to `bottom`.  This keeps
                # the virtual contiguous area intact while allowing users to nudge
                # the flow boundaries.
                if idx != 0:
                    apply_top = 0
                if idx != len(self.constituent_regions) - 1:
                    apply_bottom = 0
                # left/right apply to every region (same column width change)
            else:  # horizontal flow
                # In a horizontal flow, only the first region reacts to `left`
                # and only the last region reacts to `right`.
                if idx != 0:
                    apply_left = 0
                if idx != len(self.constituent_regions) - 1:
                    apply_right = 0
                # top/bottom apply to every region in horizontal flows

            # Skip no-op expansion to avoid extra Region objects
            needs_expansion = (
                any(v not in (0, 1.0) for v in (apply_left, apply_right, apply_top, apply_bottom))
                or width_factor != 1.0
                or height_factor != 1.0
            )

            expanded_region = (
                region.expand(
                    left=apply_left,
                    right=apply_right,
                    top=apply_top,
                    bottom=apply_bottom,
                    width_factor=width_factor,
                    height_factor=height_factor,
                    apply_exclusions=apply_exclusions,
                )
                if needs_expansion
                else region
            )
            expanded_regions.append(expanded_region)

        # Create new FlowRegion with expanded constituent regions
        return self._spawn_from_regions(expanded_regions)

    def _spawn_from_regions(self, regions: List["PhysicalRegion"]) -> "FlowRegion":
        """Create a FlowRegion clone with the supplied constituent regions."""
        new_flow_region = FlowRegion(
            flow=self.flow,
            constituent_regions=list(regions),
            source_flow_element=self.source_flow_element,
            boundary_element_found=self.boundary_element_found,
        )
        new_flow_region.source = self.source
        new_flow_region.region_type = self.region_type
        if isinstance(self.metadata, dict):
            new_flow_region.metadata = self.metadata.copy()
        else:
            new_flow_region.metadata = self.metadata
        new_flow_region._cached_text = None
        new_flow_region._cached_elements = None
        new_flow_region._cached_bbox = None
        return new_flow_region

    def _direction(
        self,
        direction: str,
        size: Optional[float] = None,
        cross_size: str = "full",
        include_source: bool = False,
        until: Optional[str] = None,
        include_endpoint: bool = True,
        offset: float = 0.0,
        apply_exclusions: bool = True,
        multipage: Optional[bool] = None,
        within: Optional["PhysicalRegion"] = None,
        anchor: str = "start",
        **kwargs,
    ) -> "FlowRegion":
        """Shared directional helper used by the navigation service."""

        regions = list(self.constituent_regions)
        if not regions:
            return self._spawn_from_regions([])

        arrangement = getattr(self.flow, "arrangement", "vertical") or "vertical"
        normalized = direction.lower()
        base_kwargs: Dict[str, Any] = {
            "include_source": include_source,
            "until": until,
            "include_endpoint": include_endpoint,
            "offset": offset,
            "apply_exclusions": apply_exclusions,
            "multipage": multipage,
            "within": within,
            "anchor": anchor,
        }
        base_kwargs.update(kwargs)

        def _call(region: "PhysicalRegion", **specific_kwargs) -> "PhysicalRegion":
            params = dict(base_kwargs)
            params.update(specific_kwargs)
            nav_fn = getattr(region, normalized)
            return cast("PhysicalRegion", nav_fn(**params))

        new_regions: List["PhysicalRegion"] = []

        if normalized in {"above", "below"}:
            target_index = 0 if normalized == "above" else len(regions) - 1
            width_arg = "element" if arrangement == "vertical" else cross_size

            def _vertical(region: "PhysicalRegion"):
                return _call(region, height=size, width=width_arg)

            if arrangement == "vertical":
                for idx, region in enumerate(regions):
                    if idx == target_index:
                        new_regions.append(_vertical(region))
                    elif include_source:
                        new_regions.append(region)
            else:
                for region in regions:
                    new_regions.append(_vertical(region))
        elif normalized in {"left", "right"}:
            target_index = 0 if normalized == "left" else len(regions) - 1
            height_arg = "element" if arrangement == "horizontal" else cross_size

            def _horizontal(region: "PhysicalRegion"):
                return _call(region, width=size, height=height_arg)

            if arrangement == "horizontal":
                for idx, region in enumerate(regions):
                    if idx == target_index:
                        new_regions.append(_horizontal(region))
                    elif include_source:
                        new_regions.append(region)
            else:
                for region in regions:
                    new_regions.append(_horizontal(region))
        else:
            raise ValueError(f"Unsupported direction '{direction}' for FlowRegion navigation.")

        return self._spawn_from_regions(new_regions)

    # Table extraction helpers (delegates to underlying physical regions)

    def extract_table(self, *args, **kwargs) -> TableResult:
        return self.services.table.extract_table(self, *args, **kwargs)

    def extract_tables(self, *args, **kwargs) -> "List[TableResult]":
        return self.services.table.extract_tables(self, *args, **kwargs)

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

    def get_sections(
        self,
        start_elements=None,
        end_elements=None,
        new_section_on_page_break: bool = False,
        include_boundaries: str = "both",
        orientation: str = "vertical",
        **kwargs: Any,
    ) -> "ElementCollection":
        """
        Extract logical sections from this FlowRegion based on start/end boundary elements.

        This delegates to the parent Flow's get_sections() method, but only operates
        on the segments that are part of this FlowRegion.

        Args:
            start_elements: Elements or selector string that mark the start of sections
            end_elements: Elements or selector string that mark the end of sections
            new_section_on_page_break: Whether to start a new section at page boundaries
            include_boundaries: How to include boundary elements: 'start', 'end', 'both', or 'none'
            orientation: 'vertical' (default) or 'horizontal' - determines section direction

        Returns:
            ElementCollection of FlowRegion objects representing the extracted sections

        Example:
            # Split a multi-page table region by headers
            table_region = flow.find("text:contains('Table 4')").below(until="text:contains('Table 5')")
            sections = table_region.get_sections(start_elements="text:bold")
        """
        # Create a temporary Flow with just our constituent regions as segments
        from natural_pdf.flows.flow import Flow

        temp_flow = Flow(
            segments=self.constituent_regions,
            arrangement=self.flow.arrangement,
            alignment=self.flow.alignment,
            segment_gap=self.flow.segment_gap,
        )

        # Delegate to Flow's get_sections implementation
        return temp_flow.get_sections(
            start_elements=start_elements,
            end_elements=end_elements,
            new_section_on_page_break=new_section_on_page_break,
            include_boundaries=include_boundaries,
            orientation=orientation,
            **kwargs,
        )

    def split(
        self, by: Optional[str] = None, page_breaks: bool = True, **kwargs
    ) -> "ElementCollection":
        """
        Split this FlowRegion into sections.

        This is a convenience method that wraps get_sections() with common splitting patterns.

        Args:
            by: Selector string for elements that mark section boundaries (e.g., "text:bold")
            page_breaks: Whether to also split at page boundaries (default: True)
            **kwargs: Additional arguments passed to get_sections()

        Returns:
            ElementCollection of FlowRegion objects representing the sections

        Example:
            # Split by bold headers
            sections = flow_region.split(by="text:bold")

            # Split only by specific text pattern, ignoring page breaks
            sections = flow_region.split(
                by="text:contains('Section')",
                page_breaks=False
            )
        """
        return self.get_sections(start_elements=by, new_section_on_page_break=page_breaks, **kwargs)

    @property
    def normalized_type(self) -> Optional[str]:
        """
        Return the normalized type for selector compatibility.
        This allows FlowRegion to be found by selectors like 'table'.
        """
        if self.region_type:
            # Convert region_type to normalized format (replace spaces with underscores, lowercase)
            return self.region_type.lower().replace(" ", "_")
        return None

    @property
    def type(self) -> Optional[str]:
        """
        Return the type attribute for selector compatibility.
        This is an alias for normalized_type.
        """
        return self.normalized_type

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

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

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

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

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

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

    def get_highlight_specs(self) -> List[Dict[str, Any]]:
        """
        Get highlight specifications for all constituent regions.

        This implements the highlighting protocol for FlowRegions, returning
        specs for each constituent region so they can be highlighted on their
        respective pages.

        Returns:
            List of highlight specification dictionaries, one for each
            constituent region.
        """
        specs = []

        for region in self.constituent_regions:
            if not hasattr(region, "page") or region.page is None:
                continue

            if not hasattr(region, "bbox") or region.bbox is None:
                continue

            spec = {
                "page": region.page,
                "page_index": region.page.index if hasattr(region.page, "index") else 0,
                "bbox": region.bbox,
                "element": region,  # Reference to the constituent region
            }

            # Add polygon if available
            if hasattr(region, "polygon") and hasattr(region, "has_polygon") and region.has_polygon:
                spec["polygon"] = region.polygon

            specs.append(spec)

        return specs
