<?php

/**
 * PageResolverTest.php
 *
 * @since       2002-08-03
 * @category    Library
 * @package     Pdf
 * @author      Nicola Asuni <info@tecnick.com>
 * @copyright   2002-2026 Nicola Asuni - Tecnick.com LTD
 * @license     https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
 * @link        https://github.com/tecnickcom/tc-lib-pdf
 *
 * This file is part of tc-lib-pdf software library.
 */

namespace Test\Import;

use Com\Tecnick\Pdf\Import\ImportCorruptedSourceException;
use Com\Tecnick\Pdf\Import\ImportPageOutOfRangeException;
use Com\Tecnick\Pdf\Import\PageResolver;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\TestCase;

class PageResolverTest extends TestCase
{
    private function invokeResolverMethod(PageResolver $resolver, string $method, mixed ...$args): mixed
    {
        $ref = new \ReflectionClass($resolver);
        return $ref->getMethod($method)->invokeArgs($resolver, $args);
    }

    /** @throws \Throwable */
    private function loadDoc(): SourceDocument
    {
        $path = __DIR__ . '/../fixtures/simple_import.pdf';
        $data = file_get_contents($path);
        $this->assertNotFalse($data);
        return new SourceDocument($data);
    }

    /**
     * @param array<int, mixed> $pairs
     * @return array<int, mixed>
     */
    private function dictObject(array $pairs): array
    {
        return [['<<', $pairs]];
    }

    /**
     * @param array<string, array<int, mixed>> $objects
     * @throws \Throwable
     */
    private function mockDoc(array $objects): SourceDocument
    {
        $doc = $this->createStub(SourceDocument::class);
        $doc->method('getTrailer')->willReturn(['root' => '1 0 R']);
        $doc->method('getObject')->willReturnCallback(static fn(string $ref): array => $objects[$ref] ?? []);
        $doc->method('findObject')->willReturnCallback(static fn(string $ref): ?array => $objects[$ref] ?? null);

        return $doc;
    }

    /** @throws \Throwable */
    public function testResolvePage1ReturnsExpectedMediaBox(): void
    {
        $resolver = new PageResolver();
        $resolved = $resolver->resolve($this->loadDoc(), 1);
        $this->assertArrayHasKey('mediaBox', $resolved);
        $this->assertCount(4, $resolved['mediaBox']);
        $this->assertEqualsWithDelta(612.0, $resolved['mediaBox'][2], 0.001);
        $this->assertEqualsWithDelta(792.0, $resolved['mediaBox'][3], 0.001);
    }

    /** @throws \Throwable */
    public function testResolvePage1HasResources(): void
    {
        $resolver = new PageResolver();
        $resolved = $resolver->resolve($this->loadDoc(), 1);
        $this->assertArrayHasKey('resources', $resolved);
    }

    /** @throws \Throwable */
    public function testResolvePage1RotateIsZero(): void
    {
        $resolver = new PageResolver();
        $resolved = $resolver->resolve($this->loadDoc(), 1);
        $this->assertSame(0, $resolved['rotate']);
    }

    /** @throws \Throwable */
    public function testResolveThrowsForPageZero(): void
    {
        $resolver = new PageResolver();
        $this->expectException(ImportPageOutOfRangeException::class);
        $resolver->resolve($this->loadDoc(), 0);
    }

    /** @throws \Throwable */
    public function testResolveThrowsForPageOutOfRange(): void
    {
        $resolver = new PageResolver();
        $this->expectException(ImportPageOutOfRangeException::class);
        $resolver->resolve($this->loadDoc(), 999);
    }

    /** @throws \Throwable */
    public function testResolveThrowsWhenRootPagesEntryIsMissing(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Catalog'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('missing /Pages entry', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    /** @throws \Throwable */
    public function testResolveParsesInheritedRotateAndIndirectResources(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
                ['/', 'MediaBox'],
                [
                    '[',
                    [
                        ['numeric', 0],
                        ['numeric', 0],
                        ['numeric', 612],
                        ['numeric', 792],
                    ],
                ],
                ['/', 'Rotate'],
                ['numeric', '180'],
                ['/', 'Resources'],
                ['objref', '4 0 R'],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
            '4_0' => $this->dictObject([
                ['/', 'Font'],
                [
                    '<<',
                    [
                        ['/', 'F1'],
                        ['objref', '5 0 R'],
                    ],
                ],
            ]),
            '5_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Font'],
            ]),
        ]);

        $resolved = $resolver->resolve($doc, 1);

        $this->assertSame(180, $resolved['rotate']);
        $this->assertArrayHasKey('Font', $resolved['resources']);
        if (!isset($resolved['resources']['Font']) || !\is_array($resolved['resources']['Font'])) {
            $this->fail('Expected Font resources array.');
        }

        $this->assertArrayHasKey('F1', $resolved['resources']['Font']);
    }

    /** @throws \Throwable */
    public function testResolveSkipsInvalidKidsAndUsesInlineResourcesAndBoxFallbacks(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['numeric', 7],
                        ['objref', '3 0 R'],
                    ],
                ],
                ['/', 'MediaBox'],
                [
                    '[',
                    [
                        ['numeric', 0],
                        ['numeric', 0],
                        ['numeric', 400],
                        ['numeric', 600],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'CropBox'],
                ['string', 'invalid'],
                ['/', 'Rotate'],
                ['numeric', 90],
                ['/', 'Resources'],
                [
                    '<<',
                    [
                        ['/', 'ProcSet'],
                        [
                            '[',
                            [
                                ['/', '/PDF'],
                                ['/', '/Text'],
                            ],
                        ],
                    ],
                ],
            ]),
        ]);

        $resolved = $resolver->resolve($doc, 1);

        $this->assertSame(90, $resolved['rotate']);
        $this->assertSame([0.0, 0.0, 400.0, 600.0], $resolved['mediaBox']);
        $this->assertSame($resolved['mediaBox'], $resolved['cropBox']);
        $this->assertSame($resolved['cropBox'], $resolved['bleedBox']);
        $this->assertSame($resolved['cropBox'], $resolved['trimBox']);
        $this->assertSame($resolved['cropBox'], $resolved['artBox']);
        $this->assertSame(['/PDF', '/Text'], $resolved['resources']['ProcSet'] ?? null);
    }

    /** @throws \Throwable */
    public function testResolveThrowsWhenResolvedPageIsMissingMediaBox(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    /** @throws \Throwable */
    public function testResolveAcceptsIndirectMediaBoxReference(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'MediaBox'],
                ['objref', '8 0 R'],
            ]),
            '8_0' => [
                [
                    '[',
                    [
                        ['numeric', 0],
                        ['numeric', 0],
                        ['numeric', 612],
                        ['numeric', 792],
                    ],
                ],
            ],
        ]);

        $resolved = $resolver->resolve($doc, 1);

        $this->assertSame([0.0, 0.0, 612.0, 792.0], $resolved['mediaBox']);
        $this->assertSame($resolved['mediaBox'], $resolved['cropBox']);
    }

    /** @throws \Throwable */
    public function testResolveThrowsWhenIndirectMediaBoxReferenceIsMissing(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'MediaBox'],
                ['objref', '999 0 R'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    /** @throws \Throwable */
    public function testResolveThrowsWhenIndirectMediaBoxObjectIsMalformed(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'MediaBox'],
                ['objref', '8 0 R'],
            ]),
            '8_0' => [
                ['keyword', 'invalid-box-token'],
            ],
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('missing /MediaBox', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    /** @throws \Throwable */
    public function testResolveMergesParentAndChildResourceDictionaries(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
                ['/', 'MediaBox'],
                [
                    '[',
                    [
                        ['numeric', 0],
                        ['numeric', 0],
                        ['numeric', 200],
                        ['numeric', 300],
                    ],
                ],
                ['/', 'Resources'],
                [
                    '<<',
                    [
                        ['/', 'Font'],
                        [
                            '<<',
                            [
                                ['/', 'F1'],
                                ['objref', '10 0 R'],
                            ],
                        ],
                        ['/', 'XObject'],
                        [
                            '<<',
                            [
                                ['/', 'Im1'],
                                ['objref', '11 0 R'],
                            ],
                        ],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'Resources'],
                [
                    '<<',
                    [
                        ['/', 'Font'],
                        [
                            '<<',
                            [
                                ['/', 'F2'],
                                ['objref', '12 0 R'],
                            ],
                        ],
                    ],
                ],
            ]),
            '10_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Font'],
            ]),
            '11_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'XObject'],
            ]),
            '12_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Font'],
            ]),
        ]);

        $resolved = $resolver->resolve($doc, 1);

        $resources = $resolved['resources'];
        $this->assertIsArray($resources);

        if (!isset($resources['Font']) || !\is_array($resources['Font'])) {
            $this->fail('Expected merged Font dictionary.');
        }

        if (!isset($resources['XObject']) || !\is_array($resources['XObject'])) {
            $this->fail('Expected inherited XObject dictionary.');
        }

        $this->assertArrayHasKey('F1', $resources['Font']);
        $this->assertArrayHasKey('F2', $resources['Font']);
        $this->assertArrayHasKey('Im1', $resources['XObject']);
    }

    /** @throws \Throwable */
    public function testResolveThrowsForPagesNodeWithoutKidsArray(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('/Kids', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    /** @throws \Throwable */
    public function testResolveThrowsForUnexpectedPageTreeNodeType(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Catalog'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('Unexpected page tree node type', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    public function testObjectToDictThrowsWhenDictionaryElementIsMissing(): void
    {
        $resolver = new PageResolver();

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('Expected dictionary object', '/') . '/');
        $this->invokeResolverMethod($resolver, 'objectToDict', ['not-an-array-element']);
    }

    public function testParseDictArraySkipsMalformedKeysAndParsesFallbackValueTypes(): void
    {
        $resolver = new PageResolver();

        /** @var array<string, mixed> $parsed */
        $parsed = $this->invokeResolverMethod($resolver, 'parseDictArray', [
            ['numeric', 1],
            ['string', 'ignored-non-name-key'],
            ['/', 123],
            ['string', 'ignored-non-string-name'],
            ['/', '/Literal'],
            'plain-text',
            ['/', '/Ref'],
            ['objref'],
            ['/', '/Unknown'],
            ['keyword', 'fallback-value'],
            ['/', '/Empty'],
            [],
        ]);

        $this->assertSame(
            [
                'Literal' => 'plain-text',
                'Ref' => '',
                'Unknown' => 'fallback-value',
                'Empty' => null,
            ],
            \array_intersect_key($parsed, [
                'Literal' => true,
                'Ref' => true,
                'Unknown' => true,
                'Empty' => true,
            ]),
        );
        $this->assertArrayNotHasKey('123', $parsed);
    }

    /**
     * A cyclic /Pages tree (intermediate nodes referencing one another, with no
     * Page leaf to terminate the descent) must be rejected instead of recursing
     * until the stack/memory is exhausted.
     *
     * @throws \Throwable
     */
    public function testResolveThrowsOnCyclicPageTree(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '2 0 R'],
                    ],
                ],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/');
        $resolver->resolve($doc, 1);
    }

    /**
     * A /Pages node listing the same kid twice is malformed (every node has a
     * single /Parent) and, if tolerated, would multiply the traversal
     * exponentially with tree depth besides corrupting page numbering.
     *
     * @throws \Throwable
     */
    public function testResolveThrowsOnDuplicateSiblingReference(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/');
        $resolver->resolve($doc, 2);
    }

    // -------------------------------------------------------------------------
    // countPages
    // -------------------------------------------------------------------------

    /** @throws \Throwable */
    public function testCountPagesReturnsReachablePageCount(): void
    {
        $resolver = new PageResolver();
        $this->assertSame(1, $resolver->countPages($this->loadDoc()));
    }

    /** @throws \Throwable */
    public function testCountPagesThrowsWhenRootPagesEntryIsMissing(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Catalog'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('missing /Pages entry', '/') . '/');
        $resolver->countPages($doc);
    }

    /** @throws \Throwable */
    public function testCountPagesReturnsZeroForEmptyKidsArray(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                ['[', []],
            ]),
        ]);

        $this->assertSame(0, $resolver->countPages($doc));
    }

    /** @throws \Throwable */
    public function testCountPagesThrowsOnDuplicateKidReference(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('Duplicate or cyclic reference', '/') . '/');
        $resolver->countPages($doc);
    }

    /** @throws \Throwable */
    public function testCountPagesThrowsWhenNodeBudgetExceeded(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('maximum node budget', '/') . '/');
        $resolver->countPages($doc, 1);
    }

    /** @throws \Throwable */
    public function testCountPagesThrowsForUnexpectedNodeType(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Catalog'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('Unexpected page tree node type', '/') . '/');
        $resolver->countPages($doc);
    }

    /** @throws \Throwable */
    public function testCountPagesThrowsForPagesNodeWithoutKids(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
            ]),
        ]);

        $this->expectException(ImportCorruptedSourceException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('missing /Kids', '/') . '/');
        $resolver->countPages($doc);
    }

    // -------------------------------------------------------------------------
    // buildPageIndex / resolveFromIndex
    // -------------------------------------------------------------------------

    /** @throws \Throwable */
    public function testBuildPageIndexReturnsPagesInDocumentOrderWithInheritedAttributes(): void
    {
        $resolver = new PageResolver();
        $doc = $this->mockDoc([
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                        ['objref', '6 0 R'],
                    ],
                ],
                ['/', 'MediaBox'],
                [
                    '[',
                    [
                        ['numeric', 0],
                        ['numeric', 0],
                        ['numeric', 300],
                        ['numeric', 500],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '4 0 R'],
                        ['objref', '5 0 R'],
                    ],
                ],
            ]),
            '4_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'Rotate'],
                ['numeric', 90],
            ]),
            '5_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'Rotate'],
                ['numeric', 180],
            ]),
            '6_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
                ['/', 'Rotate'],
                ['numeric', 270],
            ]),
        ]);

        $index = $resolver->buildPageIndex($doc);

        $this->assertCount(3, $index);
        // Leaves of the nested /Pages node come before the root's second kid.
        $this->assertSame([90, 180, 270], \array_column($index, 'Rotate'));
        // The root /Pages MediaBox is inherited by every leaf.
        foreach ($index as $pageDict) {
            $this->assertSame([0, 0, 300, 500], $pageDict['MediaBox'] ?? null);
        }
    }

    /** @throws \Throwable */
    public function testResolveFromIndexThrowsForPageZero(): void
    {
        $resolver = new PageResolver();
        $doc = $this->loadDoc();
        $index = $resolver->buildPageIndex($doc);
        $this->expectException(ImportPageOutOfRangeException::class);
        $resolver->resolveFromIndex($doc, $index, 0);
    }

    /** @throws \Throwable */
    public function testResolveFromIndexThrowsForPageBeyondIndex(): void
    {
        $resolver = new PageResolver();
        $doc = $this->loadDoc();
        $index = $resolver->buildPageIndex($doc);
        $this->expectException(ImportPageOutOfRangeException::class);
        $this->expectExceptionMessageMatches('/' . preg_quote('document has fewer pages', '/') . '/');
        $resolver->resolveFromIndex($doc, $index, 2);
    }

    /** @throws \Throwable */
    public function testResolveFromIndexDoesNotWalkTheTreeAgain(): void
    {
        $resolver = new PageResolver();
        $calls = 0;
        $objects = [
            '1_0' => $this->dictObject([
                ['/', 'Pages'],
                ['objref', '2 0 R'],
            ]),
            '2_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Pages'],
                ['/', 'Kids'],
                [
                    '[',
                    [
                        ['objref', '3 0 R'],
                    ],
                ],
                ['/', 'MediaBox'],
                [
                    '[',
                    [
                        ['numeric', 0],
                        ['numeric', 0],
                        ['numeric', 200],
                        ['numeric', 200],
                    ],
                ],
            ]),
            '3_0' => $this->dictObject([
                ['/', 'Type'],
                ['/', 'Page'],
            ]),
        ];
        $doc = $this->createStub(SourceDocument::class);
        $doc->method('getTrailer')->willReturn(['root' => '1 0 R']);
        $doc->method('getObject')->willReturnCallback(static function (string $ref) use ($objects, &$calls): array {
            ++$calls;
            return $objects[$ref] ?? [];
        });

        $index = $resolver->buildPageIndex($doc);
        $callsAfterBuild = $calls;
        $this->assertGreaterThan(0, $callsAfterBuild);

        $resolved = $resolver->resolveFromIndex($doc, $index, 1);

        $this->assertSame([0.0, 0.0, 200.0, 200.0], $resolved['mediaBox']);
        $this->assertSame($callsAfterBuild, $calls);
    }
}
