import sys
from pathlib import Path
from typing import Any

import pytest
from docling_core.types.doc import CoordOrigin
from docling_parse.pdf_parser import ContentLevel
from PIL import Image, ImageDraw, ImageStat

import docling.backend.docling_parse_backend as docling_parse_backend_module
from docling.backend.docling_parse_backend import (
    DoclingParseDocumentBackend,
    DoclingParsePageBackend,
    ThreadedDoclingParseDocumentBackend,
    ThreadedDoclingParsePageBackend,
)
from docling.backend.pdf_backend import PdfDocumentBackend
from docling.datamodel.backend_options import ThreadedDoclingParseBackendOptions
from docling.datamodel.base_models import BoundingBox, InputFormat
from docling.datamodel.document import InputDocument
from docling.datamodel.settings import DocumentLimits
from docling.pipeline.standard_pdf_pipeline import StandardPdfPipeline


@pytest.fixture
def test_doc_path():
    return Path("./tests/data/pdf/sources/2206.01062.pdf")


def _get_backend(pdf_doc):
    in_doc = InputDocument(
        path_or_stream=pdf_doc,
        format=InputFormat.PDF,
        backend=DoclingParseDocumentBackend,
    )

    doc_backend = in_doc._backend
    return doc_backend


def test_text_cell_counts():
    pdf_doc = Path("./tests/data/pdf/sources/redp5110_sampled.pdf")

    doc_backend = _get_backend(pdf_doc)

    for page_index in range(doc_backend.page_count()):
        last_cell_count = None
        for i in range(10):
            page_backend: DoclingParsePageBackend = doc_backend.load_page(0)
            cells = list(page_backend.get_text_cells())

            if last_cell_count is None:
                last_cell_count = len(cells)

            if len(cells) != last_cell_count:
                assert False, (
                    "Loading page multiple times yielded non-identical text cell counts"
                )
            last_cell_count = len(cells)

            # Clean up page backend after each iteration
            page_backend.unload()

    # Explicitly clean up document backend to prevent race conditions in CI
    doc_backend.unload()


def test_get_text_from_rect(test_doc_path):
    doc_backend = _get_backend(test_doc_path)
    page_backend: DoclingParsePageBackend = doc_backend.load_page(0)

    # Get the title text of the DocLayNet paper
    textpiece = page_backend.get_text_in_rect(
        bbox=BoundingBox(l=102, t=77, r=511, b=124)
    )
    ref = "DocLayNet: A Large Human-Annotated Dataset for Document-Layout Analysis"

    assert textpiece.strip() == ref

    # Explicitly clean up resources
    page_backend.unload()
    doc_backend.unload()


def test_crop_page_image(test_doc_path):
    doc_backend = _get_backend(test_doc_path)
    page_backend: DoclingParsePageBackend = doc_backend.load_page(0)

    # Crop out "Figure 1" from the DocLayNet paper
    page_backend.get_page_image(
        scale=2, cropbox=BoundingBox(l=317, t=246, r=574, b=527)
    )
    # im.show()

    # Explicitly clean up resources
    page_backend.unload()
    doc_backend.unload()


def test_num_pages(test_doc_path):
    doc_backend = _get_backend(test_doc_path)
    assert doc_backend.page_count() == 9

    # Explicitly clean up resources to prevent race conditions in CI
    doc_backend.unload()


def test_iter_pages_default_contract(test_doc_path):
    doc_backend = _get_backend(test_doc_path)

    page_numbers = []
    page_backends = []
    try:
        for index, page_backend in enumerate(doc_backend.iter_pages()):
            page_numbers.append(page_backend.page_no)
            page_backends.append(page_backend)
            if index == 2:
                break
    finally:
        for page_backend in page_backends:
            page_backend.unload()
        doc_backend.unload()

    assert page_numbers == [1, 2, 3]


def test_standard_pipeline_default_backend_loads_only_requested_page_range(
    test_doc_path,
):
    loaded_pages: list[int] = []

    class CountingDoclingParseDocumentBackend(DoclingParseDocumentBackend):
        def load_page(
            self,
            page_no: int,
            create_words: bool = True,
            create_textlines: bool = True,
        ) -> DoclingParsePageBackend:
            loaded_pages.append(page_no + 1)
            return super().load_page(
                page_no,
                create_words=create_words,
                create_textlines=create_textlines,
            )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=CountingDoclingParseDocumentBackend,
        limits=DocumentLimits(page_range=(2, 2)),
    )
    doc_backend = in_doc._backend
    assert isinstance(doc_backend, PdfDocumentBackend)
    pipeline = StandardPdfPipeline.__new__(StandardPdfPipeline)
    page_backends = []

    try:
        page_backends = list(
            pipeline._iter_requested_page_backends(doc_backend, expected_page_nos=[2])
        )

        assert [page_backend.page_no for page_backend in page_backends] == [2]
        assert loaded_pages == [2]
        assert doc_backend.supports_random_page_access is True
    finally:
        for page_backend in page_backends:
            page_backend.unload()
        doc_backend.unload()


class _FakeThreadedResult:
    def __init__(
        self,
        *,
        page_number: int,
        success: bool = True,
        page_width: float = 100.0,
        page_height: float = 200.0,
    ) -> None:
        self.page_number = page_number
        self.success = success
        self.page_width = page_width
        self.page_height = page_height
        self.cropboxes: list[BoundingBox | None] = []
        self.scales: list[float] = []

    def get_page(self) -> Any:
        raise AssertionError("get_page() is not expected in this test")

    def get_image(
        self,
        *,
        scale: float | None = None,
        canvas_size=None,
        cropbox: BoundingBox | None = None,
    ):
        from PIL import Image

        assert canvas_size is None
        self.scales.append(1.0 if scale is None else scale)
        self.cropboxes.append(cropbox)
        width = round(self.page_width if cropbox is None else cropbox.width)
        height = round(self.page_height if cropbox is None else cropbox.height)
        scaled_width = max(1, round(width * (1.0 if scale is None else scale)))
        scaled_height = max(1, round(height * (1.0 if scale is None else scale)))
        return Image.new("RGBA", (scaled_width, scaled_height), (255, 255, 255, 255))


class _FakeThreadedParser:
    created: "_FakeThreadedParser | None" = None

    def __init__(self, parser_config=None, decode_config=None) -> None:
        self.parser_config = parser_config
        self.decode_config = decode_config
        self.load_calls: list[list[int] | None] = []
        self.unload_calls: list[str] = []
        _FakeThreadedParser.created = self

    def load(self, path_or_stream, password=None, page_numbers=None) -> str:
        self.load_calls.append(page_numbers)
        return "doc-key"

    def page_count(self, doc_key: str) -> int:
        assert doc_key == "doc-key"
        return 5

    def iterate_results(self):
        yield _FakeThreadedResult(page_number=3)
        yield _FakeThreadedResult(page_number=2)

    def unload(self, doc_key: str) -> bool:
        self.unload_calls.append(doc_key)
        return True


class _FakePdfiumDocument:
    def __init__(self, path_or_stream, password=None) -> None:
        self.path_or_stream = path_or_stream
        self.password = password

    def __len__(self) -> int:
        return 5

    def close(self) -> None:
        return None


def test_threaded_backend_iterates_requested_pages_and_unloads(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        limits=DocumentLimits(page_range=(2, 3)),
    )

    doc_backend = in_doc._backend
    assert isinstance(doc_backend, ThreadedDoclingParseDocumentBackend)
    assert doc_backend.page_count() == 5

    page_numbers = [page_backend.page_no for page_backend in doc_backend.iter_pages()]
    assert page_numbers == [3, 2]

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.load_calls == [[2, 3]]

    doc_backend.unload()
    assert parser.unload_calls == ["doc-key"]


def test_threaded_backend_open_ended_page_range_is_clipped_to_document(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        limits=DocumentLimits(page_range=(2, sys.maxsize)),
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.load_calls == [[2, 3, 4, 5]]

    in_doc._backend.unload()


def test_threaded_backend_bounded_page_range_is_clipped_to_document(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        limits=DocumentLimits(page_range=(2, 99)),
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.load_calls == [[2, 3, 4, 5]]

    in_doc._backend.unload()


def test_standard_pipeline_threaded_backend_loads_only_requested_page_range(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        limits=DocumentLimits(page_range=(2, 2)),
    )
    doc_backend = in_doc._backend
    assert isinstance(doc_backend, PdfDocumentBackend)
    pipeline = StandardPdfPipeline.__new__(StandardPdfPipeline)

    try:
        page_backends = list(
            pipeline._iter_requested_page_backends(doc_backend, expected_page_nos=[2])
        )

        parser = _FakeThreadedParser.created
        assert parser is not None
        assert parser.load_calls == [[2]]
        assert [page_backend.page_no for page_backend in page_backends] == [2]
        assert doc_backend.supports_random_page_access is False
    finally:
        doc_backend.unload()


def test_threaded_backend_no_page_range_passes_none_without_page_count_probe(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    class _FailingPdfiumDocument:
        def __init__(self, path_or_stream, password=None) -> None:
            raise AssertionError("page count should not be probed for default ranges")

    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FailingPdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        # no limits → default page_range (1, sys.maxsize)
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.load_calls == [None]

    in_doc._backend.unload()


def test_threaded_backend_uses_backend_option_thread_count(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    class _FakeAcceleratorOptions:
        def __init__(self) -> None:
            self.num_threads = 7

    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.AcceleratorOptions",
        _FakeAcceleratorOptions,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        backend_options=ThreadedDoclingParseBackendOptions(parser_threads=11),
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.parser_config is not None
    assert parser.parser_config.threads == 11
    assert parser.parser_config.page_content_config is not None
    assert (
        parser.parser_config.page_content_config.char_cells_content_level
        == ContentLevel.COMPUTE
    )
    assert (
        parser.parser_config.page_content_config.word_cells_content_level
        == ContentLevel.COMPUTE_AND_MATERIALIZE
    )
    assert (
        parser.parser_config.page_content_config.line_cells_content_level
        == ContentLevel.COMPUTE_AND_MATERIALIZE
    )
    assert (
        parser.parser_config.page_content_config.shapes_content_level
        == ContentLevel.SKIP
    )
    assert (
        parser.parser_config.page_content_config.bitmaps_content_level
        == ContentLevel.COMPUTE_AND_MATERIALIZE
    )
    assert parser.parser_config.page_content_config.include_bitmap_bytes is False
    assert parser.decode_config is not None
    assert parser.decode_config.enforce_same_font is True
    assert parser.decode_config.release_native_memory_every_n_pages == 128

    in_doc._backend.unload()


def test_threaded_backend_uses_backend_option_native_memory_release_interval(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        backend_options=ThreadedDoclingParseBackendOptions(
            release_native_memory_every_n_pages=64
        ),
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.parser_config is not None
    assert parser.parser_config.page_content_config is not None
    assert parser.parser_config.page_content_config.include_bitmap_bytes is False
    assert parser.decode_config is not None
    assert parser.decode_config.release_native_memory_every_n_pages == 64

    in_doc._backend.unload()


def test_threaded_backend_allows_disabling_native_memory_release(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
        backend_options=ThreadedDoclingParseBackendOptions(
            release_native_memory_every_n_pages=0
        ),
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.parser_config is not None
    assert parser.parser_config.page_content_config is not None
    assert parser.parser_config.page_content_config.include_bitmap_bytes is False
    assert parser.decode_config is not None
    assert parser.decode_config.release_native_memory_every_n_pages == 0

    in_doc._backend.unload()


def test_threaded_backend_uses_accelerator_thread_count_when_unset(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
):
    class _FakeAcceleratorOptions:
        def __init__(self) -> None:
            self.num_threads = 7

    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.AcceleratorOptions",
        _FakeAcceleratorOptions,
    )

    in_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
    )

    parser = _FakeThreadedParser.created
    assert parser is not None
    assert parser.parser_config is not None
    assert parser.parser_config.threads == 7
    assert parser.parser_config.page_content_config is not None
    assert parser.parser_config.page_content_config.include_bitmap_bytes is False
    assert parser.decode_config is not None

    in_doc._backend.unload()


def test_non_threaded_page_backend_disables_bitmap_byte_materialization() -> None:
    captured_content_config: Any | None = None

    class _FakeCell:
        def to_top_left_origin(self, _page_height: float) -> "_FakeCell":
            return self

    class _FakeDimension:
        height = 200.0

    class _FakeSegmentedPage:
        dimension = _FakeDimension()
        textline_cells = [_FakeCell()]
        char_cells = [_FakeCell()]
        word_cells = [_FakeCell()]

    class _FakePdfDocument:
        def get_page(
            self,
            _page_no: int,
            *,
            content_config: Any,
        ) -> _FakeSegmentedPage:
            nonlocal captured_content_config
            captured_content_config = content_config
            return _FakeSegmentedPage()

        def unload_pages(self, _page_range: tuple[int, int]) -> None:
            return None

    class _FakePdfPage:
        def close(self) -> None:
            return None

    page_backend = DoclingParsePageBackend(
        dp_doc=_FakePdfDocument(),
        page_obj=_FakePdfPage(),
        page_no=0,
    )

    try:
        cells = list(page_backend.get_text_cells())
    finally:
        page_backend.unload()

    assert len(cells) == 1
    assert captured_content_config is not None
    assert captured_content_config.include_bitmap_bytes is False


def test_threaded_backend_creates_fresh_default_options_per_instance(
    test_doc_path, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.DoclingThreadedPdfParser",
        _FakeThreadedParser,
    )
    monkeypatch.setattr(
        "docling.backend.docling_parse_backend.pdfium.PdfDocument",
        _FakePdfiumDocument,
    )

    first_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
    )
    second_doc = InputDocument(
        path_or_stream=test_doc_path,
        format=InputFormat.PDF,
        backend=ThreadedDoclingParseDocumentBackend,
    )

    try:
        assert first_doc._backend.options is not second_doc._backend.options
    finally:
        first_doc._backend.unload()
        second_doc._backend.unload()


def test_threaded_page_backend_delegates_image_access() -> None:
    result = _FakeThreadedResult(page_number=4, page_width=120.0, page_height=90.0)
    page_backend = ThreadedDoclingParsePageBackend(result)
    cropbox = BoundingBox(l=10, t=5, r=40, b=25)

    image = page_backend.get_page_image(scale=2.0, cropbox=cropbox)

    assert page_backend.page_no == 4
    assert page_backend.get_size().width == 120.0
    assert page_backend.get_size().height == 90.0
    assert page_backend.is_valid() is True
    assert image.size == (60, 40)
    assert result.scales == [2.0]
    assert result.cropboxes == [cropbox]


def test_threaded_page_backend_disables_bitmap_materialization() -> None:
    class _FakeCell:
        def to_top_left_origin(self, _page_height: float) -> "_FakeCell":
            return self

    class _FakeDimension:
        height = 200.0

    class _FakeSegmentedPage:
        dimension = _FakeDimension()
        textline_cells = [_FakeCell()]
        char_cells = [_FakeCell()]
        word_cells = [_FakeCell()]
        bitmap_resources: list[Any] = []

    result = _FakeThreadedResult(page_number=4)
    result.get_page = lambda: _FakeSegmentedPage()

    page_backend = ThreadedDoclingParsePageBackend(result)

    cells = list(page_backend.get_text_cells())

    assert len(cells) == 1


def _create_black_square_pdf(path: Path) -> None:
    image = Image.new("RGB", (100, 100), "white")
    draw = ImageDraw.Draw(image)
    draw.rectangle((20, 40, 49, 69), fill="black")
    image.save(path, "PDF", resolution=72.0)


def _load_first_page_backend(doc_backend: Any) -> Any:
    if isinstance(doc_backend, ThreadedDoclingParseDocumentBackend):
        return next(doc_backend.iter_pages())
    return doc_backend.load_page(0)


@pytest.mark.parametrize(
    "backend_cls",
    [DoclingParseDocumentBackend, ThreadedDoclingParseDocumentBackend],
    ids=["docling_parse", "threaded_docling_parse"],
)
@pytest.mark.parametrize("scale", [1, 2], ids=["scale_1", "scale_2"])
def test_get_page_image_crop_contains_black_square(
    tmp_path: Path, backend_cls: Any, scale: int
) -> None:
    pdf_path = tmp_path / "black_square.pdf"
    _create_black_square_pdf(pdf_path)

    cropbox = BoundingBox(
        l=21,
        t=41,
        r=49,
        b=69,
        coord_origin=CoordOrigin.TOPLEFT,
    )
    full_square_cropbox = BoundingBox(
        l=20,
        t=40,
        r=50,
        b=70,
        coord_origin=CoordOrigin.TOPLEFT,
    )
    white_cropbox = BoundingBox(
        l=0,
        t=0,
        r=10,
        b=10,
        coord_origin=CoordOrigin.TOPLEFT,
    )

    in_doc = InputDocument(
        path_or_stream=pdf_path,
        format=InputFormat.PDF,
        backend=backend_cls,
    )
    doc_backend = in_doc._backend
    page_backend = _load_first_page_backend(doc_backend)

    try:
        black_crop = page_backend.get_page_image(scale=scale, cropbox=cropbox).convert(
            "RGB"
        )
        white_crop = page_backend.get_page_image(
            scale=scale, cropbox=white_cropbox
        ).convert("RGB")
    finally:
        page_backend.unload()
        doc_backend.unload()

    assert black_crop.size == (28 * scale, 28 * scale)
    assert white_crop.size == (10 * scale, 10 * scale)
    assert full_square_cropbox.width == 30
    assert full_square_cropbox.height == 30

    black_extrema = black_crop.getextrema()
    assert black_extrema is not None
    assert all(channel_max <= 8 for _, channel_max in black_extrema)

    black_mean = ImageStat.Stat(black_crop).mean
    white_mean = ImageStat.Stat(white_crop).mean
    assert all(channel_mean < 5.0 for channel_mean in black_mean)
    assert all(channel_mean > 250.0 for channel_mean in white_mean)
