import numbers
import re
from collections.abc import Callable, Generator, Iterable
from functools import lru_cache
from re import Pattern
from typing import TYPE_CHECKING, ClassVar, Protocol, cast
from unicodedata import normalize as normalize_unicode
from warnings import warn

from pdfminer.converter import PDFPageAggregator
from pdfminer.layout import (
    LTChar,
    LTContainer,
    LTCurve,
    LTPage,
    LTTextContainer,
)
from pdfminer.pdfinterp import PDFPageInterpreter, PDFStackT
from pdfminer.pdfpage import PDFPage
from pdfminer.psparser import PSLiteral

from . import utils
from ._typing import (
    ContainerWithMarkedContent,
    PageAggregatorRenderHooks,
    T_any_kwargs,
    T_bbox,
    T_kwargs,
    T_mapping_obj,
    T_num,
    T_obj,
    T_obj_list,
    T_struct_list,
)
from .container import Container
from .structure import PDFStructTree, StructTreeMissing
from .table import T_table_settings, Table, TableFinder, TableSettings
from .utils import decode_text, resolve_all, resolve_and_decode
from .utils.exceptions import MalformedPDFException, PdfminerException
from .utils.text import TextMap

lt_pat: Pattern[str] = re.compile(r"^LT")

ALL_ATTRS = {
    "adv",
    "height",
    "linewidth",
    "pts",
    "size",
    "srcsize",
    "width",
    "x0",
    "x1",
    "y0",
    "y1",
    "bits",
    "matrix",
    "upright",
    "fontname",
    "text",
    "imagemask",
    "colorspace",
    "evenodd",
    "fill",
    "non_stroking_color",
    "stroke",
    "stroking_color",
    "stream",
    "name",
    "mcid",
    "tag",
}


if TYPE_CHECKING:  # pragma: nocover
    from .display import PageImage
    from .pdf import PDF


class _CachedTextMapGetter(Protocol):
    def __call__(self, **kwargs: object) -> TextMap: ...

    def cache_clear(self) -> None: ...


# via https://git.ghostscript.com/?p=mupdf.git;a=blob;f=source/pdf/pdf-font.c;h=6322cedf2c26cfb312c0c0878d7aff97b4c7470e;hb=HEAD#l774   # noqa

CP936_FONTNAMES = {
    b"\xcb\xce\xcc\xe5": "SimSun,Regular",
    b"\xba\xda\xcc\xe5": "SimHei,Regular",
    b"\xbf\xac\xcc\xe5_GB2312": "SimKai,Regular",
    b"\xb7\xc2\xcb\xce_GB2312": "SimFang,Regular",
    b"\xc1\xa5\xca\xe9": "SimLi,Regular",
}


def fix_fontname_bytes(fontname: bytes) -> str:
    if b"+" in fontname:
        split_at = fontname.index(b"+") + 1
        prefix, suffix = fontname[:split_at], fontname[split_at:]
    else:
        prefix, suffix = b"", fontname

    suffix_new = CP936_FONTNAMES.get(suffix, str(suffix)[2:-1])
    return str(prefix)[2:-1] + suffix_new


def tuplify_list_kwargs(kwargs: T_kwargs) -> T_kwargs:
    return {
        key: (tuple(value) if isinstance(value, list) else value)
        for key, value in kwargs.items()
    }


class PDFPageAggregatorWithMarkedContent(PDFPageAggregator):
    """Extract layout from a specific page, adding marked-content IDs to
    objects where found."""

    cur_mcid: int | None = None
    cur_tag: str | None = None

    def begin_tag(self, tag: PSLiteral, props: PDFStackT | None = None) -> None:
        """Handle beginning of tag, setting current MCID if any."""
        self.cur_tag = decode_text(tag.name)
        if isinstance(props, dict) and "MCID" in props:
            self.cur_mcid = props["MCID"]
        else:
            self.cur_mcid = None

    def end_tag(self) -> None:
        """Handle beginning of tag, clearing current MCID."""
        self.cur_tag = None
        self.cur_mcid = None

    def tag_cur_item(self) -> None:
        """Add current MCID to what we hope to be the most recent object created
        by pdfminer.six."""
        # This is somewhat hacky and would not be necessary if
        # pdfminer.six supported MCIDs.  In reading the code it's
        # clear that the `render_*` methods methods will only ever
        # create one object, but that is far from being guaranteed.
        # Even if pdfminer.six's API would just return the objects it
        # creates, we wouldn't have to do this.
        cur_item = cast("ContainerWithMarkedContent", self.cur_item)
        if cur_item._objs:
            cur_obj = cur_item._objs[-1]
            cur_obj.mcid = self.cur_mcid
            cur_obj.tag = self.cur_tag

    def render_char(self, *args: object, **kwargs: object) -> float:
        """Hook for rendering characters, adding the `mcid` attribute."""
        hooks = cast("PageAggregatorRenderHooks", super())
        adv = hooks.render_char(*args, **kwargs)
        self.tag_cur_item()
        return adv

    def render_image(self, *args: object, **kwargs: object) -> None:
        """Hook for rendering images, adding the `mcid` attribute."""
        hooks = cast("PageAggregatorRenderHooks", super())
        hooks.render_image(*args, **kwargs)
        self.tag_cur_item()

    def paint_path(self, *args: object, **kwargs: object) -> None:
        """Hook for rendering lines and curves, adding the `mcid` attribute."""
        hooks = cast("PageAggregatorRenderHooks", super())
        hooks.paint_path(*args, **kwargs)
        self.tag_cur_item()


def _normalize_box(box_raw: T_bbox, rotation: T_num = 0) -> T_bbox:
    # Per PDF Reference 3.8.4: "Note: Although rectangles are
    # conventionally specified by their lower-left and upperright
    # corners, it is acceptable to specify any two diagonally opposite
    # corners."
    if not all(isinstance(x, numbers.Number) for x in box_raw):  # pragma: nocover
        raise MalformedPDFException(
            f"Bounding box contains non-number coordinate(s): {box_raw}"
        )
    x0, x1 = sorted((box_raw[0], box_raw[2]))
    y0, y1 = sorted((box_raw[1], box_raw[3]))
    if rotation in [90, 270]:
        return (y0, x0, y1, x1)
    else:
        return (x0, y0, x1, y1)


# PDFs coordinate spaces refer to an origin in the bottom-left of the
# page; pdfplumber flips this vertically, so that the origin is in the
# top-left.
def _invert_box(box_raw: T_bbox, mb_height: T_num) -> T_bbox:
    x0, y0, x1, y1 = box_raw
    return (x0, mb_height - y1, x1, mb_height - y0)


class Page(Container):
    cached_properties: ClassVar[list[str]] = [*Container.cached_properties, "_layout"]
    is_original: bool = True
    pages: list["Page"] | None = None
    pdf: "PDF"
    root_page: "Page"
    page_obj: PDFPage
    page_number: int
    initial_doctop: T_num
    rotation: int
    mediabox: T_bbox
    cropbox: T_bbox
    bbox: T_bbox
    get_textmap: _CachedTextMapGetter
    _layout: LTPage
    _objects: dict[str, T_obj_list]

    def __init__(
        self,
        pdf: "PDF",
        page_obj: PDFPage,
        page_number: int,
        initial_doctop: T_num = 0,
    ) -> None:
        self.pdf = pdf
        self.root_page = self
        self.page_obj = page_obj
        self.page_number = page_number
        self.initial_doctop = initial_doctop
        missing = object()

        def get_attr(key: str, default: object | None = None) -> object | None:
            value = resolve_all(page_obj.attrs.get(key))
            return default if value is None else value

        def as_num(value: object, name: str) -> T_num:
            if isinstance(value, bool):
                return int(value)
            if isinstance(value, (int, float)):
                return value
            raise MalformedPDFException(
                f"{name} contains non-number coordinate(s): {value!r}"
            )

        def as_rotation(value: object | None) -> int:
            if value is None:
                return 0
            if isinstance(value, bool):
                return int(value)
            if isinstance(value, int):
                return value
            if isinstance(value, float):
                return int(value)
            raise MalformedPDFException(f"Rotation is not numeric: {value!r}")

        def as_bbox(value: object | None, name: str) -> T_bbox | None:
            if value is None:
                return None
            if not isinstance(value, (list, tuple)) or len(value) != 4:
                raise MalformedPDFException(
                    f"{name} must be a 4-item box, got {value!r}"
                )
            x0 = as_num(value[0], name)
            y0 = as_num(value[1], name)
            x1 = as_num(value[2], name)
            y1 = as_num(value[3], name)
            return (x0, y0, x1, y1)

        # Per PDF Reference Table 3.27: "The number of degrees by which the
        # page should be rotated clockwise when displayed or printed. The value
        # must be a multiple of 90. Default value: 0"
        rotation_src = getattr(page_obj, "rotate", None)
        if rotation_src is None:
            rotation_src = get_attr("Rotate", 0)
        self.rotation = as_rotation(rotation_src) % 360

        mb_src = as_bbox(getattr(page_obj, "mediabox", None), "mediabox")
        if mb_src is None:
            mb_src = as_bbox(get_attr("MediaBox"), "MediaBox")
        if mb_src is None:
            raise MalformedPDFException("MediaBox is missing")
        mb_raw = _normalize_box(mb_src, self.rotation)
        mb_height = mb_raw[3] - mb_raw[1]

        self.mediabox = _invert_box(mb_raw, mb_height)

        crop_raw = getattr(page_obj, "cropbox", missing)
        crop_src = as_bbox(crop_raw if crop_raw is not missing else None, "cropbox")
        if crop_raw is missing:
            crop_src = as_bbox(get_attr("CropBox"), "CropBox")
        if crop_src is None:
            self.cropbox = self.mediabox
        else:
            self.cropbox = _invert_box(
                _normalize_box(crop_src, self.rotation),
                mb_height,
            )

        for attr_name, box_name in (
            ("trimbox", "TrimBox"),
            ("bleedbox", "BleedBox"),
            ("artbox", "ArtBox"),
        ):
            box_raw = getattr(page_obj, attr_name, missing)
            box_src = as_bbox(box_raw if box_raw is not missing else None, attr_name)
            if box_raw is missing:
                box_src = as_bbox(get_attr(box_name), box_name)
            if box_src is None:
                continue
            box_normalized = _invert_box(
                _normalize_box(box_src, self.rotation),
                mb_height,
            )
            setattr(self, attr_name, box_normalized)

        # Page.bbox defaults to self.mediabox, but can be altered by Page.crop(...)
        self.bbox = self.mediabox

        # See https://rednafi.com/python/lru_cache_on_methods/
        self.get_textmap = cast("_CachedTextMapGetter", lru_cache()(self._get_textmap))

    def close(self) -> None:
        self.flush_cache()
        self.get_textmap.cache_clear()

    @property
    def width(self) -> T_num:
        return self.bbox[2] - self.bbox[0]

    @property
    def height(self) -> T_num:
        return self.bbox[3] - self.bbox[1]

    @property
    def structure_tree(self) -> T_struct_list:
        """Return the structure tree for a page, if any."""
        try:
            return [elem.to_dict() for elem in PDFStructTree(self.pdf, self)]
        except StructTreeMissing:
            return []

    @property
    def layout(self) -> LTPage:
        if hasattr(self, "_layout"):
            return self._layout
        device = PDFPageAggregatorWithMarkedContent(
            self.pdf.rsrcmgr,
            pageno=self.page_number,
            laparams=self.pdf.laparams,
        )
        interpreter = PDFPageInterpreter(self.pdf.rsrcmgr, device)
        try:
            interpreter.process_page(self.page_obj)
        except Exception as e:
            raise PdfminerException(e) from e
        self._layout: LTPage = device.get_result()
        return self._layout

    @property
    def annots(self) -> T_obj_list:
        def rotate_point(pt: tuple[float, float], r: int) -> tuple[float, float]:
            turns = r // 90
            for i in range(turns):
                x, y = pt
                comp = self.width if i == turns % 2 else self.height
                pt = (y, (comp - x))
            return pt

        def parse(annot: T_obj) -> T_obj:
            _a, _b, _c, _d = annot["Rect"]
            pt0 = rotate_point((_a, _b), self.rotation)
            pt1 = rotate_point((_c, _d), self.rotation)
            rh = self.root_page.height
            x0, top, x1, bottom = _invert_box(_normalize_box((*pt0, *pt1)), rh)

            a = annot.get("A", {})
            extras = {
                "uri": a.get("URI"),
                "title": annot.get("T"),
                "contents": annot.get("Contents"),
            }
            for k, v in extras.items():
                if v is not None:
                    try:
                        extras[k] = v.decode("utf-8")
                    except UnicodeDecodeError:
                        try:
                            extras[k] = v.decode("utf-16")
                        except UnicodeDecodeError:
                            if self.pdf.raise_unicode_errors:
                                raise
                            warn(
                                f"Could not decode {k} of annotation."
                                f" {k} will be missing.",
                                stacklevel=2,
                            )

            parsed = {
                "page_number": self.page_number,
                "object_type": "annot",
                "x0": x0,
                "y0": rh - bottom,
                "x1": x1,
                "y1": rh - top,
                "doctop": self.initial_doctop + top,
                "top": top,
                "bottom": bottom,
                "width": x1 - x0,
                "height": bottom - top,
            }
            parsed.update(extras)
            # Replace the indirect reference to the page dictionary
            # with a pointer to our actual page
            if "P" in annot:
                annot["P"] = self
            parsed["data"] = annot
            return parsed

        raw_obj = resolve_all(self.page_obj.annots)
        raw = raw_obj if isinstance(raw_obj, list) else []
        parsed = [parse(cast("T_obj", annot)) for annot in raw]
        if isinstance(self, CroppedPage):
            return self._crop_fn(parsed)
        else:
            return parsed

    @property
    def hyperlinks(self) -> T_obj_list:
        return [a for a in self.annots if a["uri"] is not None]

    @property
    def objects(self) -> dict[str, T_obj_list]:
        if hasattr(self, "_objects"):
            return self._objects
        self._objects: dict[str, T_obj_list] = self.parse_objects()
        return self._objects

    def point2coord(self, pt: tuple[T_num, T_num]) -> tuple[T_num, T_num]:
        # See note below re. #1181 and mediabox-adjustment reversions
        return (self.mediabox[0] + pt[0], self.mediabox[1] + self.height - pt[1])

    def process_object(self, obj: object) -> T_obj:
        kind = re.sub(lt_pat, "", obj.__class__.__name__).lower()
        attr = {}

        def _normalize_color_value(value: object) -> tuple[object, ...]:
            if isinstance(value, list):
                return tuple(value)
            if isinstance(value, tuple):
                return value
            return (value,)

        for key in ALL_ATTRS:
            if hasattr(obj, key):
                attr[key] = resolve_all(getattr(obj, key))

        attr["object_type"] = kind
        attr["page_number"] = self.page_number

        for cs in ["ncs", "scs"]:
            # Note: As of pdfminer.six v20221105, that library only
            # exposes ncs for LTChars, and neither attribute for
            # other objects. Keeping this code here, though,
            # for ease of addition if color spaces become
            # more available via pdfminer.six
            if hasattr(obj, cs):
                attr[cs] = resolve_and_decode(getattr(obj, cs).name)

        if isinstance(obj, (LTChar, LTTextContainer)):
            text = obj.get_text()
            attr["text"] = (
                normalize_unicode(self.pdf.unicode_norm, text)
                if self.pdf.unicode_norm is not None
                else text
            )

        if isinstance(obj, LTChar):
            # pdfminer.six (at least as of v20221105) does not
            # directly expose .stroking_color and .non_stroking_color
            # for LTChar objects (unlike, e.g., LTRect objects).
            gs = obj.graphicstate
            attr["stroking_color"] = _normalize_color_value(gs.scolor)
            attr["non_stroking_color"] = _normalize_color_value(gs.ncolor)
            if attr["stroking_color"] == (0.0,):
                attr["stroking_color"] = (0,)
            # For LTChar parity, do not emit an scs field.
            attr.pop("scs", None)

            # Handle (rare) byte-encoded fontnames
            if isinstance(attr["fontname"], bytes):  # pragma: nocover
                attr["fontname"] = fix_fontname_bytes(attr["fontname"])

        elif isinstance(obj, (LTCurve,)):
            attr["pts"] = list(map(self.point2coord, attr["pts"]))

            def _iter_path_segments(
                path: object,
            ) -> Generator[tuple[object, list[tuple[T_num, T_num]]], None, None]:
                for raw_segment in cast("list[object]", path or []):
                    segment = cast("list[object] | tuple[object, ...]", raw_segment)
                    if len(segment) == 2:
                        cmd = segment[0]
                        candidate = segment[1]
                        if (
                            isinstance(candidate, (list, tuple))
                            and candidate
                            and isinstance(candidate[0], (list, tuple))
                        ):
                            pts = [
                                cast("tuple[T_num, T_num]", point)
                                for point in candidate
                            ]
                        elif isinstance(candidate, (list, tuple)) and not candidate:
                            pts = []
                        else:
                            pts = [cast("tuple[T_num, T_num]", candidate)]
                    else:
                        cmd = segment[0]
                        pts = [
                            cast("tuple[T_num, T_num]", point) for point in segment[1:]
                        ]
                    yield cmd, pts

            attr["path"] = [
                (cmd, *map(self.point2coord, pts))
                for cmd, pts in _iter_path_segments(obj.original_path)
            ]

            attr["dash"] = obj.dashing_style

        for color_key in ("stroking_color", "non_stroking_color"):
            if color_key in attr and isinstance(attr[color_key], list):
                attr[color_key] = tuple(attr[color_key])

        if kind == "image":
            attr.setdefault("stream", None)

        # As noted in #1181, `pdfminer.six` adjusts objects'
        # coordinates relative to the MediaBox:
        # https://github.com/pdfminer/pdfminer.six/blob/1a8bd2f730295b31d6165e4d95fcb5a03793c978/pdfminer/converter.py#L79-L84
        mb_x0, mb_top = self.mediabox[:2]

        if "y0" in attr:
            attr["top"] = (self.height - attr["y1"]) + mb_top
            attr["bottom"] = (self.height - attr["y0"]) + mb_top
            attr["doctop"] = self.initial_doctop + attr["top"]

        if "x0" in attr and mb_x0 != 0:
            attr["x0"] = attr["x0"] + mb_x0
            attr["x1"] = attr["x1"] + mb_x0

        return attr

    def iter_layout_objects(
        self, layout_objects: Iterable[object]
    ) -> Generator[T_obj, None, None]:
        for obj in layout_objects:
            # If object is, like LTFigure, a higher-level object ...
            if isinstance(obj, LTContainer):
                # and LAParams is passed, process the object itself.
                if self.pdf.laparams is not None:
                    yield self.process_object(obj)
                # Regardless, iterate through its children
                yield from self.iter_layout_objects(obj._objs)
            else:
                yield self.process_object(obj)

    def parse_objects(self) -> dict[str, T_obj_list]:
        objects: dict[str, T_obj_list] = {}
        for obj in self.iter_layout_objects(self.layout._objs):
            kind = obj["object_type"]
            if kind in ["anno"]:
                continue
            if objects.get(kind) is None:
                objects[kind] = []
            objects[kind].append(obj)
        return objects

    def debug_tablefinder(
        self, table_settings: T_table_settings | None = None
    ) -> TableFinder:
        tset = TableSettings.resolve(table_settings)
        return TableFinder(self, tset)

    def find_tables(
        self, table_settings: T_table_settings | None = None
    ) -> list[Table]:
        tset = TableSettings.resolve(table_settings)
        return TableFinder(self, tset).tables

    def find_table(
        self, table_settings: T_table_settings | None = None
    ) -> Table | None:
        tset = TableSettings.resolve(table_settings)
        tables = self.find_tables(tset)

        if len(tables) == 0:
            return None

        # Return the largest table, as measured by number of cells.
        def sorter(x: Table) -> tuple[int, T_num, T_num]:
            return (-len(x.cells), x.bbox[1], x.bbox[0])

        largest = sorted(tables, key=sorter)[0]

        return largest

    def extract_tables(
        self, table_settings: T_table_settings | None = None
    ) -> list[list[list[str | None]]]:
        tset = TableSettings.resolve(table_settings)
        tables = self.find_tables(tset)
        return [table.extract(**(tset.text_settings or {})) for table in tables]

    def extract_table(
        self, table_settings: T_table_settings | None = None
    ) -> list[list[str | None]] | None:
        tset = TableSettings.resolve(table_settings)
        table = self.find_table(tset)
        if table is None:
            return None
        else:
            return table.extract(**(tset.text_settings or {}))

    def _get_textmap(self, **kwargs: object) -> TextMap:
        defaults: dict[str, object] = {"layout_bbox": self.bbox}
        if "layout_width_chars" not in kwargs:
            defaults.update({"layout_width": self.width})
        if "layout_height_chars" not in kwargs:
            defaults.update({"layout_height": self.height})
        full_kwargs: dict[str, object] = {**defaults, **kwargs}
        return utils.chars_to_textmap(self.chars, **full_kwargs)

    def search(
        self,
        pattern: str | Pattern[str],
        regex: bool = True,
        case: bool = True,
        main_group: int = 0,
        return_chars: bool = True,
        return_groups: bool = True,
        **kwargs: object,
    ) -> list[T_mapping_obj]:
        textmap = self.get_textmap(**tuplify_list_kwargs(kwargs))
        return textmap.search(
            pattern,
            regex=regex,
            case=case,
            main_group=main_group,
            return_chars=return_chars,
            return_groups=return_groups,
        )

    def extract_text(self, **kwargs: object) -> str:
        defaults: dict[str, object] = {"layout_bbox": self.bbox}
        full_kwargs = tuplify_list_kwargs(kwargs)
        if "layout_width_chars" not in full_kwargs:
            defaults.update({"layout_width": self.width})
        if "layout_height_chars" not in full_kwargs:
            defaults.update({"layout_height": self.height})
        text_kwargs = cast("T_any_kwargs", {**defaults, **full_kwargs})
        return utils.extract_text(self.chars, **text_kwargs)

    def extract_text_simple(self, **kwargs: object) -> str:
        return utils.extract_text_simple(
            self.chars,
            **cast("T_any_kwargs", kwargs),
        )

    def extract_words(self, **kwargs: object) -> T_obj_list:
        return utils.extract_words(self.chars, **cast("T_any_kwargs", kwargs))

    def extract_text_lines(
        self, strip: bool = True, return_chars: bool = True, **kwargs: object
    ) -> T_obj_list:
        return self.get_textmap(**tuplify_list_kwargs(kwargs)).extract_text_lines(
            strip=strip, return_chars=return_chars
        )

    def crop(
        self, bbox: T_bbox, relative: bool = False, strict: bool = True
    ) -> "CroppedPage":
        return CroppedPage(self, bbox, relative=relative, strict=strict)

    def within_bbox(
        self, bbox: T_bbox, relative: bool = False, strict: bool = True
    ) -> "CroppedPage":
        """
        Same as .crop, except only includes objects fully within the bbox
        """
        return CroppedPage(
            self, bbox, relative=relative, strict=strict, crop_fn=utils.within_bbox
        )

    def outside_bbox(
        self, bbox: T_bbox, relative: bool = False, strict: bool = True
    ) -> "CroppedPage":
        """
        Same as .crop, except only includes objects fully within the bbox
        """
        return CroppedPage(
            self, bbox, relative=relative, strict=strict, crop_fn=utils.outside_bbox
        )

    def filter(self, test_function: Callable[[T_obj], bool]) -> "FilteredPage":
        return FilteredPage(self, test_function)

    def dedupe_chars(self, **kwargs: object) -> "FilteredPage":
        """
        Removes duplicate chars - those sharing the same text and positioning
        (within `tolerance`) as other characters in the set. Adjust extra_args
        to be more/less restrictive with the properties checked.
        """
        p = FilteredPage(self, lambda x: True)
        p._objects = dict(self.objects)
        p._objects["char"] = utils.dedupe_chars(
            self.chars,
            **cast("T_any_kwargs", kwargs),
        )
        return p

    def to_image(
        self,
        resolution: int | float | None = None,
        width: int | float | None = None,
        height: int | float | None = None,
        antialias: bool = False,
        force_mediabox: bool = False,
    ) -> "PageImage":
        """
        You can pass a maximum of 1 of the following:
        - resolution: The desired number pixels per inch. Defaults to 72.
        - width: The desired image width in pixels.
        - height: The desired image width in pixels.
        """
        from .display import DEFAULT_RESOLUTION, PageImage

        num_specs = sum(x is not None for x in [resolution, width, height])
        if num_specs > 1:
            raise ValueError(
                f"Only one of these arguments can be provided: resolution, width, height. You provided {num_specs}"  # noqa: E501
            )
        elif width is not None:
            resolution = 72 * width / self.width
        elif height is not None:
            resolution = 72 * height / self.height

        return PageImage(
            self,
            resolution=resolution or DEFAULT_RESOLUTION,
            antialias=antialias,
            force_mediabox=force_mediabox,
        )

    def to_dict(self, object_types: list[str] | None = None) -> T_mapping_obj:
        if object_types is None:
            _object_types = [*list(self.objects.keys()), "annot"]
        else:
            _object_types = object_types
        d = {
            "page_number": self.page_number,
            "initial_doctop": self.initial_doctop,
            "rotation": self.rotation,
            "cropbox": self.cropbox,
            "mediabox": self.mediabox,
            "bbox": self.bbox,
            "width": self.width,
            "height": self.height,
        }
        for t in _object_types:
            d[t + "s"] = getattr(self, t + "s")
        return d

    def __repr__(self) -> str:
        return f"<Page:{self.page_number}>"


class DerivedPage(Page):
    is_original: bool = False
    parent_page: Page
    root_page: Page
    pdf: "PDF"
    page_obj: PDFPage
    page_number: int
    initial_doctop: T_num
    rotation: int
    mediabox: T_bbox
    cropbox: T_bbox
    get_textmap: _CachedTextMapGetter

    def __init__(self, parent_page: Page) -> None:
        self.parent_page = parent_page
        self.root_page = parent_page.root_page
        self.pdf = parent_page.pdf
        self.page_obj = parent_page.page_obj
        self.page_number = parent_page.page_number
        self.initial_doctop = parent_page.initial_doctop
        self.rotation = parent_page.rotation
        self.mediabox = parent_page.mediabox
        self.cropbox = parent_page.cropbox
        self.flush_cache(Container.cached_properties)
        self.get_textmap = cast("_CachedTextMapGetter", lru_cache()(self._get_textmap))


def test_proposed_bbox(bbox: T_bbox, parent_bbox: T_bbox) -> None:
    bbox_area = utils.calculate_area(bbox)
    if bbox_area == 0:
        raise ValueError(f"Bounding box {bbox} has an area of zero.")

    overlap = utils.get_bbox_overlap(bbox, parent_bbox)
    if overlap is None:
        raise ValueError(
            f"Bounding box {bbox} is entirely outside "
            f"parent page bounding box {parent_bbox}"
        )

    overlap_area = utils.calculate_area(overlap)
    if overlap_area < bbox_area:
        raise ValueError(
            f"Bounding box {bbox} is not fully within "
            f"parent page bounding box {parent_bbox}"
        )


class CroppedPage(DerivedPage):
    _crop_fn: Callable[[T_obj_list], T_obj_list]
    bbox: T_bbox
    _objects: dict[str, T_obj_list]

    def __init__(
        self,
        parent_page: Page,
        crop_bbox: T_bbox,
        crop_fn: Callable[[T_obj_list, T_bbox], T_obj_list] = utils.crop_to_bbox,
        relative: bool = False,
        strict: bool = True,
    ) -> None:
        if relative:
            o_x0, o_top, _, _ = parent_page.bbox
            x0, top, x1, bottom = crop_bbox
            crop_bbox = (x0 + o_x0, top + o_top, x1 + o_x0, bottom + o_top)

        if strict:
            test_proposed_bbox(crop_bbox, parent_page.bbox)

        def _crop_fn(objs: T_obj_list) -> T_obj_list:
            return crop_fn(objs, crop_bbox)

        super().__init__(parent_page)

        self._crop_fn = _crop_fn

        # Note: testing for original function passed, not _crop_fn
        if crop_fn is utils.outside_bbox:
            self.bbox = parent_page.bbox
        else:
            self.bbox = crop_bbox

    @property
    def objects(self) -> dict[str, T_obj_list]:
        if hasattr(self, "_objects"):
            return self._objects
        self._objects: dict[str, T_obj_list] = {
            k: self._crop_fn(v) for k, v in self.parent_page.objects.items()
        }
        return self._objects


class FilteredPage(DerivedPage):
    bbox: T_bbox
    filter_fn: Callable[[T_obj], bool]
    _objects: dict[str, T_obj_list]

    def __init__(self, parent_page: Page, filter_fn: Callable[[T_obj], bool]) -> None:
        self.bbox = parent_page.bbox
        self.filter_fn = filter_fn
        super().__init__(parent_page)

    @property
    def objects(self) -> dict[str, T_obj_list]:
        if hasattr(self, "_objects"):
            return self._objects
        self._objects: dict[str, T_obj_list] = {
            k: list(filter(self.filter_fn, v))
            for k, v in self.parent_page.objects.items()
        }
        return self._objects
