"""Test the pypdf.generic._data_structures module."""
import os
import subprocess
import sys
from io import BytesIO
from pathlib import Path
from typing import Callable
from unittest import mock

import pytest

from pypdf import PdfReader, PdfWriter
from pypdf.errors import LimitReachedError, PdfReadError
from pypdf.generic import (
    ArrayObject,
    ByteStringObject,
    ContentStream,
    DictionaryObject,
    NameObject,
    NullObject,
    NumberObject,
    RectangleObject,
    StreamObject,
    TextStringObject,
    TreeObject,
)
from tests import RESOURCE_ROOT, get_data_from_url

try:
    import resource
except ImportError:
    resource = None  # type: ignore[assignment]


def test_dictionary_object__get_next_object_position() -> None:
    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")

    # reader.xref = {0: {7: 15, 9: 10245, 12: 939, 14: 2999, 16: 4982, 18: 9949, 22: 11160}}
    assert DictionaryObject._get_next_object_position(
        position_before=12345, position_end=999999, generations=list(reader.xref), pdf=reader
    ) == 999999  # No value after 12345 in dictionary
    assert DictionaryObject._get_next_object_position(
        position_before=11111, position_end=999999, generations=list(reader.xref), pdf=reader
    ) == 11160  # First value after 11111 in dictionary.
    assert DictionaryObject._get_next_object_position(
        position_before=42, position_end=999999, generations=list(reader.xref), pdf=reader
    ) == 939  # First value after 42 in dictionary.

    # New generation.
    reader.xref[1] = {7: 42, 24: 15000}
    assert DictionaryObject._get_next_object_position(
        position_before=10, position_end=999999, generations=list(reader.xref), pdf=reader
    ) == 15


def test_tree_object__cyclic_reference(caplog: pytest.LogCaptureFixture) -> None:
    writer = PdfWriter()
    child1_object = DictionaryObject()
    child1 = writer._add_object(child1_object)
    child2 = writer._add_object(DictionaryObject({NameObject("/Next"): child1}))
    child3 = writer._add_object(DictionaryObject({NameObject("/Next"): child2}))
    child1_object[NameObject("/Next")] = child3
    tree = TreeObject()
    tree[NameObject("/First")] = child2
    tree[NameObject("/Last")] = writer._add_object(DictionaryObject())

    assert list(tree.children()) == [child2.get_object(), child1.get_object(), child3.get_object()]
    assert "Detected cycle in outline structure for " in caplog.text


def test_tree_object__insert_child_without_next_key() -> None:
    """Regression test for TreeObject.insert_child without /Next."""
    writer = PdfWriter()
    tree = TreeObject()
    writer._add_object(tree)

    first_child_ref = writer._add_object(DictionaryObject())
    tree.add_child(first_child_ref, writer)

    fresh_child = DictionaryObject()
    fresh_child_ref = writer._add_object(fresh_child)
    assert "/Next" not in fresh_child

    # Must not raise KeyError("/Next") even though the new child has no /Next.
    tree.insert_child(fresh_child_ref, first_child_ref, writer)

    # The new child is linked before the existing one.
    first_child = first_child_ref.get_object()
    assert isinstance(first_child, DictionaryObject)
    assert fresh_child["/Next"] == first_child
    assert first_child["/Prev"] == fresh_child


def test_tree_object__insert_child_in_first_position_with_next() -> None:
    """Regression test: child with /Next inserted in first position."""
    writer = PdfWriter()
    tree = TreeObject()
    writer._add_object(tree)

    # Create a single child A as the /First (and /Last)
    child_a = DictionaryObject()
    child_a_ref = writer._add_object(child_a)
    tree.add_child(child_a_ref, writer)

    # prev = tree["/Last"] = A, prev == before, so no while loop.
    # try: prev["/Prev"] — A has no /Prev → KeyError → except block fires.

    # Create child C with a /Next pointer pointing to something
    child_c = DictionaryObject()
    child_c[NameObject("/Next")] = child_a_ref  # C -> A
    child_c_ref = writer._add_object(child_c)
    assert "/Next" in child_c

    # Insert C before A (first position)
    tree.insert_child(child_c_ref, child_a_ref, writer)

    # C is now first, linked to A
    c_obj = child_c_ref.get_object()
    assert isinstance(c_obj, DictionaryObject)
    assert c_obj["/Next"] == child_a


@pytest.mark.enable_socket
def test_array_object__clone_same_object_multiple_times(caplog: pytest.LogCaptureFixture) -> None:
    url = "https://github.com/user-attachments/files/25412858/Draft_OSMF_financial_statement_2013.pdf"
    name = "issue2991.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))

    writer = PdfWriter()
    for page in reader.pages:
        page2 = writer.add_page(page)
        assert page2.mediabox == RectangleObject((0, 0, 595, 841))
    assert caplog.messages == []


def test_array_object__clone_same_stream_multiple_times() -> None:
    writer = PdfWriter()

    # Unique streams.
    stream1 = StreamObject()
    stream1.set_data(b"Hello World!")
    stream2 = StreamObject()
    stream2.set_data(b"Lorem ipsum!")

    # Shared streams.
    shared_streams = [StreamObject() for _ in range(3)]
    for index, shared_stream in enumerate(shared_streams):
        shared_stream.set_data(f"Shared stream {index}".encode())

    # Add to writer.
    writer._add_object(stream1)
    writer._add_object(stream2)
    shared_references = [writer._add_object(shared_stream) for shared_stream in shared_streams]

    # Arrays.
    array1 = ArrayObject([stream1.indirect_reference, *shared_references])
    array2 = ArrayObject([stream2.indirect_reference, *shared_references])

    # Cloned.
    cloned1 = array1.clone(pdf_dest=writer)
    cloned2 = array2.clone(pdf_dest=writer)

    # Nullify one shared object.
    writer._replace_object(shared_references[1].indirect_reference, NullObject())

    # The first entry is always different. The remaining shared entries should be dedicated copies.
    assert cloned1[1:] != cloned2[1:]

    assert ContentStream(stream=array1, pdf=None).get_data() == b"Hello World!\nShared stream 0\nShared stream 2\n"
    assert ContentStream(stream=array2, pdf=None).get_data() == b"Lorem ipsum!\nShared stream 0\nShared stream 2\n"
    assert (
        ContentStream(stream=cloned1, pdf=None).get_data() ==
        b"Hello World!\nShared stream 0\nShared stream 1\nShared stream 2\n"
    )
    assert (
        ContentStream(stream=cloned2, pdf=None).get_data() ==
        b"Lorem ipsum!\nShared stream 0\nShared stream 1\nShared stream 2\n"
    )


def test_array_object__to_lst_conversion() -> None:
    arr = ArrayObject()

    # str not starting with "/" -> TextStringObject
    arr += "hello"
    assert isinstance(arr[0], TextStringObject)

    # bytes -> ByteStringObject
    arr += b"data"
    assert isinstance(arr[1], ByteStringObject)

    # number (else branch) - should pass through unwrapped
    arr += 42
    assert arr[2] == 42
    assert type(arr[2]) is int


@pytest.mark.enable_socket
def test_dictionary_object__read_from_stream__limit() -> None:
    name = "read_from_stream__length_2gb.pdf"
    url = "https://github.com/user-attachments/files/25842437/read_from_stream__length_2gb.pdf"

    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    page = reader.pages[0]

    with pytest.raises(
            expected_exception=LimitReachedError,
            match=r"^Declared stream length of 2147483647 exceeds maximum allowed length\.$"
    ):
        page.extract_text()


def _prepare_test_dictionary_object__read_from_stream__no_limit(
        path: Path
) -> tuple[str, dict[str, str], Callable[[], None]]:
    env = os.environ.copy()
    env["COVERAGE_PROCESS_START"] = "pyproject.toml"

    name = "read_from_stream__length_2gb.pdf"
    url = "https://github.com/user-attachments/files/25842437/read_from_stream__length_2gb.pdf"
    data = get_data_from_url(url=url, name=name)
    pdf_path = path / name
    pdf_path.write_bytes(data)
    pdf_path_str = pdf_path.resolve().as_posix()

    try:
        env["PYTHONPATH"] = "." + os.pathsep + env["PYTHONPATH"]
    except KeyError:
        env["PYTHONPATH"] = "."

    def limit_virtual_memory() -> None:
        limit_kb = 1_000_000
        limit_bytes = limit_kb * 1024
        resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, limit_bytes))

    return pdf_path_str, env, limit_virtual_memory


@pytest.mark.enable_socket
@pytest.mark.skipif(condition=resource is None, reason="Does not have 'resource' module.")
@pytest.mark.skipif(sys.platform == "darwin", reason="RLIMIT_AS is unreliable.")
def test_dictionary_object__read_from_stream__no_limit(tmp_path: Path) -> None:
    pdf_path_str, env, limit_virtual_memory = _prepare_test_dictionary_object__read_from_stream__no_limit(tmp_path)

    source_file = tmp_path / "script.py"
    source_file.write_text(
        f"""
import sys
from pypdf import filters, PdfReader

filters.MAX_DECLARED_STREAM_LENGTH = sys.maxsize

with open({pdf_path_str!r}, mode="rb") as fd:
    reader = PdfReader(fd)
    print(reader.pages[0].extract_text())
"""
    )

    result = subprocess.run(  # noqa: S603  # We have the control here.
        [sys.executable, source_file],
        capture_output=True,
        env=env,
        text=True,
        preexec_fn=limit_virtual_memory,
    )
    assert result.returncode == 1
    assert result.stdout == ""
    assert result.stderr.replace("\r", "").endswith("\nMemoryError\n")


@pytest.mark.enable_socket
@pytest.mark.skipif(condition=resource is None, reason="Does not have 'resource' module.")
@pytest.mark.skipif(sys.platform == "darwin", reason="RLIMIT_AS is unreliable.")
def test_dictionary_object__read_from_stream__no_limit__path(tmp_path: Path) -> None:
    pdf_path_str, env, limit_virtual_memory = _prepare_test_dictionary_object__read_from_stream__no_limit(tmp_path)

    source_file = tmp_path / "script.py"
    source_file.write_text(
        f"""
import sys
from pypdf import filters, PdfReader

filters.MAX_DECLARED_STREAM_LENGTH = sys.maxsize

reader = PdfReader({pdf_path_str!r})
print(reader.pages[0].extract_text())
"""
    )

    result = subprocess.run(  # noqa: S603  # We have the control here.
        [sys.executable, source_file],
        capture_output=True,
        env=env,
        text=True,
        preexec_fn=limit_virtual_memory,
    )
    assert result.returncode == 0
    assert result.stdout.replace("\r", "") == "Hello from pypdf\n"
    assert result.stderr == ""


def _get_array_based_buffer(stream_count: int, chunk_bytes: int) -> BytesIO:
    writer = PdfWriter()
    page = writer.add_blank_page(width=10, height=10)

    streams = [ContentStream(stream=None, pdf=writer) for _ in range(stream_count)]
    chunk = b"q\n" + (b"A" * chunk_bytes) + b"\nQ\n"
    for stream in streams:
        stream.set_data(chunk)
    contents = ArrayObject([writer._add_object(stream) for stream in streams])
    page[NameObject("/Contents")] = contents

    buffer = BytesIO()
    writer.write(buffer)
    buffer.flush()
    return buffer


@pytest.mark.timeout(10)
def test_content_stream__array_based__performance() -> None:
    buffer = _get_array_based_buffer(stream_count=10_000, chunk_bytes=7000)
    reader = PdfReader(buffer)
    _ = reader.pages[0].get_contents()


def test_content_stream__array_based__length() -> None:
    buffer = _get_array_based_buffer(stream_count=11_000, chunk_bytes=1)
    reader = PdfReader(buffer)
    with pytest.raises(
            expected_exception=LimitReachedError, match=r"^Array\-based stream has 11000 > 10000 elements\.$"
    ):
        _ = reader.pages[0].get_contents()


@pytest.mark.timeout(10)
def test_content_stream__array_based__output_length() -> None:
    buffer = _get_array_based_buffer(stream_count=10_000, chunk_bytes=8192)
    reader = PdfReader(buffer)
    with pytest.raises(
            expected_exception=LimitReachedError,
            match=r"^Array\-based stream has at least 75002550 > 75000000 output bytes\.$"
    ):
        _ = reader.pages[0].get_contents()


@pytest.mark.timeout(5)
def test_dictionary_object__read_from_stream__infinite_loop(caplog: pytest.LogCaptureFixture) -> None:
    data = b"""1 0 obj
<<
<</Length 1 0 R>>
stream
trailer
<< /Size 1 >>
startxref
0
%%EOF
"""
    buffer = BytesIO(data)

    reader = PdfReader(buffer, strict=False)
    with pytest.raises(expected_exception=PdfReadError, match=r"^Cannot find Root object in pdf$"):
        assert len(reader.pages) == 0


@pytest.mark.timeout(5)
def test_dictionary_object__get_inherited__cyclic() -> None:
    writer = PdfWriter()

    dictionary1 = DictionaryObject()
    reference1 = writer._add_object(dictionary1)
    dictionary2 = DictionaryObject()
    reference2 = writer._add_object(dictionary2)
    dictionary3 = DictionaryObject({NameObject("/Parent"): reference2})
    reference3 = writer._add_object(dictionary3)
    dictionary1[NameObject("/Parent")] = reference3
    dictionary2[NameObject("/Parent")] = reference1

    with pytest.raises(
            expected_exception=LimitReachedError,
            match=r"^Detected cycle in /Parent hierarchy when retrieving value for key '/FT'\.$"
    ):
        writer.get_pages_showing_field(reference1)


def _make_pdf__read_from_stream__limit() -> bytes:
    offsets = []
    pdf = bytearray(b"%PDF-1.4\n")

    def add_obj(n: int, body: bytes) -> None:
        offsets.append((n, len(pdf)))
        pdf.extend(f"{n} 0 obj\n".encode())
        pdf.extend(body)
        pdf.extend(b"\nendobj\n")

    add_obj(1, b"<< /Type /Catalog /Pages 2 0 R >>")
    add_obj(2, b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
    add_obj(3, b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 4 0 R /Resources << >> >>")
    offsets.append((4, len(pdf)))
    pdf.extend(b"4 0 obj\n")
    pdf.extend(b"<< >>\nstream\n")
    pdf.extend(b"A" * 75_000_001)
    pdf.extend(b"\nendstream\nendobj\n")
    xref_offset = len(pdf)
    pdf.extend(b"xref\n0 5\n0000000000 65535 f \n")
    offset_map = dict(offsets)
    for n in range(1, 5):
        pdf.extend(f"{offset_map[n]:010d} 00000 n \n".encode())
    pdf.extend(f"trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode())
    return bytes(pdf)


@pytest.mark.timeout(5)
def test_dictionary_object__read_from_stream__missing_length__limit() -> None:
    reader = PdfReader(BytesIO(_make_pdf__read_from_stream__limit()))
    page = reader.pages[0]

    with pytest.raises(
            expected_exception=LimitReachedError,
            match=r"^Read stream length of 75000187 exceeds maximum allowed length of 75000000\.$"
    ):
        page.get_contents()


def test_dictionary_object__read_from_stream__read_unsized__limit() -> None:
    reader = PdfReader(RESOURCE_ROOT / "issue-301.pdf")

    with mock.patch.object(DictionaryObject, "_read_unsized_from_stream", return_value=b"dummy") as read_mock:
        obj = reader.get_object(13)
        assert obj is not None
        assert obj == DictionaryObject({
            NameObject("/Filter"): NameObject("/FlateDecode"),
            NameObject("/Length1"): NumberObject(218)
        })
    read_mock.assert_called_once_with(stream=reader.stream, pdf=reader, length=75_000_000)


def test_dictionary_object__read_unsized_from_stream__limit() -> None:
    reader = PdfReader(RESOURCE_ROOT / "issue-301.pdf")

    reader.stream.seek(63101, 0)  # pstart value from the corresponding call
    with pytest.raises(
            expected_exception=LimitReachedError,
            match=r"^Requested length of 236 exceeds maximum allowed length\.$"
    ):
        DictionaryObject._read_unsized_from_stream(stream=reader.stream, pdf=reader, length=137)


def test_content_stream__parse_content_stream__limits() -> None:
    content_stream = ContentStream(stream=None, pdf=None)

    stream = BytesIO(b"/Do TESTING\n")
    content_stream._parse_content_stream(stream)
    assert content_stream.operations == [(["/Do"], b"TESTING")]

    stream = BytesIO(f"/Do {'TESTING' * 100}\n".encode())
    with pytest.raises(
            expected_exception=LimitReachedError,
            match=r"^Read stream length of 240 exceeds maximum allowed length of 128\.$"
    ):
        content_stream._parse_content_stream(stream)


@pytest.mark.timeout(5)
def test_content_stream__read_inline_image__end_of_stream() -> None:
    # Broken content stream, for example due to filter errors.
    # Specific example:
    #   Error -3 while decompressing data: invalid distance too far back
    #   b'q 0.1 0 0 0.1 0 0 cm\n/R7 gs\n0 g\nq 4.8 0 0 -135.6 2155.08 7150.48 cm\nBI\n/IM true\n/W001'
    content_stream = ContentStream(stream=None, pdf=None)

    with pytest.raises(expected_exception=PdfReadError, match=r"^Unexpected end of stream\.$"):
        content_stream._read_inline_image(BytesIO(b"\n/IM true\n/W001"))


def test_tree_object__insert_child__cycle() -> None:
    writer = PdfWriter()

    previous1 = TreeObject()
    previous2 = TreeObject()
    previous3 = TreeObject()
    previous1[NameObject("/Next")] = writer._add_object(previous2)
    previous2[NameObject("/Next")] = writer._add_object(previous3)
    previous3[NameObject("/Next")] = writer._add_object(previous1)

    tree = TreeObject()
    tree[NameObject("/Last")] = writer._add_object(previous1)
    tree[NameObject("/First")] = writer._add_object(DictionaryObject())
    writer._add_object(tree)

    with pytest.raises(LimitReachedError, match=r"^Detected cycle in tree structure\.$"):
        tree.insert_child(
            child=writer._add_object(DictionaryObject()),
            before=None,
            pdf=writer,
        )
