<?php

/**
 * ResourceClonerTest.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\ObjectMap;
use Com\Tecnick\Pdf\Import\ResourceCloner;
use Com\Tecnick\Pdf\Import\SourceDocument;
use PHPUnit\Framework\TestCase;

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

    /**
     * @param array<string, mixed> $objects
     * @throws \Throwable
     */
    private function makeMockSourceDocument(array $objects): SourceDocument
    {
        $src = $this->createStub(SourceDocument::class);

        $src->method('getObject')->willReturnCallback(static fn(string $ref): array => (
            isset($objects[$ref]) && \is_array($objects[$ref]) ? $objects[$ref] : []
        ));

        $src->method('findObject')->willReturnCallback(static fn(string $ref): ?array => isset($objects[$ref])
            && \is_array($objects[$ref])
                ? $objects[$ref]
                : null);

        return $src;
    }

    // -------------------------------------------------------------------------
    // getPon
    // -------------------------------------------------------------------------

    public function testGetPonReturnsInitialValue(): void
    {
        $cloner = new ResourceCloner(10);
        $this->assertSame(10, $cloner->getPon());
    }

    /** @throws \Throwable */
    public function testGetPonUpdatesAfterEnqueue(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        // Enqueuing font object 5_0 must allocate a new destination number.
        $destNum = $cloner->enqueueObject('5_0', $src, $map);
        $this->assertGreaterThan(0, $destNum);
        $this->assertSame($destNum, $cloner->getPon());
    }

    // -------------------------------------------------------------------------
    // getContentStream
    // -------------------------------------------------------------------------

    /** @throws \Throwable */
    public function testGetContentStreamEmptyPageReturnsEmptyStream(): void
    {
        $src = $this->loadFixture();
        $cloner = new ResourceCloner(0);

        $result = $cloner->getContentStream([], $src);
        $this->assertSame('', $result['bytes']);
        $this->assertSame('', $result['filter']);
        $this->assertSame(0, $result['length']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamSingleRef(): void
    {
        $src = $this->loadFixture();
        $cloner = new ResourceCloner(0);

        // Object 4_0 is the content stream in the fixture.
        $pageDict = ['Contents' => '4_0'];
        $result = $cloner->getContentStream($pageDict, $src);

        $this->assertNotEmpty($result['bytes']);
        $this->assertStringContainsString('BT', $result['bytes']);
        $this->assertSame(\strlen($result['bytes']), $result['length']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamArrayWithSingleRef(): void
    {
        $src = $this->loadFixture();
        $cloner = new ResourceCloner(0);

        // Array of one ref — should behave identically to single-ref case.
        $pageDict = ['Contents' => ['4_0']];
        $result = $cloner->getContentStream($pageDict, $src);

        $this->assertNotEmpty($result['bytes']);
        $this->assertStringContainsString('BT', $result['bytes']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamMultipleRefsAreConcatenated(): void
    {
        $src = $this->loadFixture();
        $cloner = new ResourceCloner(0);

        // Use the same stream twice to test the concatenation path.
        $pageDict = ['Contents' => ['4_0', '4_0']];
        $result = $cloner->getContentStream($pageDict, $src);

        // Bytes must appear twice in the concatenated output.
        $singleStream = $cloner->getContentStream(['Contents' => '4_0'], $src);
        $this->assertStringContainsString($singleStream['bytes'], $result['bytes']);
        $this->assertGreaterThan($singleStream['length'], $result['length']);
        // Multi-stream concatenation always returns empty filter.
        $this->assertSame('', $result['filter']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamThrowsForInvalidArrayEntry(): void
    {
        $src = $this->loadFixture();
        $cloner = new ResourceCloner(0);

        // A non-string element inside the /Contents array is invalid.
        $pageDict = ['Contents' => [42]];
        $this->expectException(ImportCorruptedSourceException::class);
        $cloner->getContentStream($pageDict, $src);
    }

    // -------------------------------------------------------------------------
    // cloneResources
    // -------------------------------------------------------------------------

    /** @throws \Throwable */
    public function testCloneResourcesEmptyDictReturnsEmptyString(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $this->assertSame('', $cloner->cloneResources([], $src, $map));
    }

    /** @throws \Throwable */
    public function testCloneResourcesFontRefsAreRemapped(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        // Minimal resource dict: Font -> F1 -> indirect ref to object 5_0.
        $resources = ['Font' => ['F1' => '5_0']];
        $output = $cloner->cloneResources($resources, $src, $map);

        // Output must start with << and contain a /Font entry.
        $this->assertStringStartsWith('<<', $output);
        $this->assertStringContainsString('/Font', $output);
        // The ref to 5_0 must be remapped to a new object number in "N 0 R" format.
        $this->assertMatchesRegularExpression('/\d+ 0 R/', $output);
    }

    /** @throws \Throwable */
    public function testCloneResourcesProcSetSkipped(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $resources = ['ProcSet' => ['/PDF', '/Text']];
        $output = $cloner->cloneResources($resources, $src, $map);

        // ProcSet is re-emitted as a standard fixed list, so source array is ignored.
        $this->assertStringContainsString('/ProcSet', $output);
    }

    /** @throws \Throwable */
    public function testCloneResourcesSerializesNestedEntriesAndPreservesInlineScalars(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $output = $cloner->cloneResources(
            [
                'ColorSpace' => [
                    'CS1' => '/DeviceRGB',
                    'CS2' => ['/DeviceCMYK'],
                ],
            ],
            $src,
            $map,
        );

        $this->assertStringContainsString('/CS1 /DeviceRGB', $output);
        $this->assertStringContainsString('/CS2 [ /DeviceCMYK ]', $output);
    }

    /** @throws \Throwable */
    public function testCloneResourcesSupportsScalarResourceValues(): void
    {
        $src = $this->makeMockSourceDocument([
            '5_0' => [
                ['numeric', 99],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $indirectOutput = $cloner->cloneResources(['XObject' => '5 0 R'], $src, $map);
        $inlineOutput = $cloner->cloneResources(['ColorSpace' => '/DeviceCMYK'], $src, $map);
        $nullOutput = $cloner->cloneResources(['Pattern' => 123], $src, $map);

        $this->assertMatchesRegularExpression('/\/XObject \d+ 0 R/', $indirectOutput);
        $this->assertStringContainsString('/ColorSpace /DeviceCMYK', $inlineOutput);
        $this->assertStringContainsString('/Pattern null', $nullOutput);
    }

    /** @throws \Throwable */
    public function testCloneResourcesPreservesNumericResourceNames(): void
    {
        $src = $this->makeMockSourceDocument([
            '9_0' => [
                ['numeric', 1],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $output = $cloner->cloneResources(
            [
                'Font' => [
                    9 => '9_0',
                    'n' => '9_0',
                ],
            ],
            $src,
            $map,
        );

        $this->assertStringContainsString('/Font << /9 ', $output);
        $this->assertStringContainsString('/n ', $output);
    }

    /** @throws \Throwable */
    public function testGetContentStreamRejectsUnexpectedContentsType(): void
    {
        $src = $this->loadFixture();
        $cloner = new ResourceCloner(0);

        $this->expectException(ImportCorruptedSourceException::class);
        $cloner->getContentStream(['Contents' => 42], $src);
    }

    /** @throws \Throwable */
    public function testGetContentStreamMultipleRefsSkipsInvalidEntriesAndReturnsNamedFilter(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Filter'],
                        ['/', 'FlateDecode'],
                    ],
                ],
                ['stream', 'alpha'],
            ],
            '2_0' => [
                [
                    '<<',
                    [
                        ['/', 'Length'],
                        ['numeric', 4],
                    ],
                ],
                ['stream', 'beta'],
            ],
        ]);
        $cloner = new ResourceCloner(0);

        $single = $cloner->getContentStream(['Contents' => '1_0'], $src);
        $combined = $cloner->getContentStream(['Contents' => ['1_0', 99, '2_0']], $src);

        $this->assertSame('/FlateDecode', $single['filter']);
        $this->assertSame('alpha beta', $combined['bytes']);
        $this->assertSame('', $combined['filter']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamParsesArrayFilterToken(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Filter'],
                        [
                            '[',
                            [
                                ['/', 'FlateDecode'],
                            ],
                        ],
                    ],
                ],
                ['stream', 'alpha'],
            ],
        ]);
        $cloner = new ResourceCloner(0);

        $single = $cloner->getContentStream(['Contents' => '1_0'], $src);

        $this->assertSame('/FlateDecode', $single['filter']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamParsesArrayFilterTokenWithMultipleNames(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Filter'],
                        [
                            '[',
                            [
                                ['/', 'ASCII85Decode'],
                                ['/', 'FlateDecode'],
                            ],
                        ],
                    ],
                ],
                ['stream', 'alpha'],
            ],
        ]);
        $cloner = new ResourceCloner(0);

        $single = $cloner->getContentStream(['Contents' => '1_0'], $src);

        $this->assertSame('[ /ASCII85Decode /FlateDecode ]', $single['filter']);
    }

    /** @throws \Throwable */
    public function testGetContentStreamMultipleFlateRefsAreDecodedBeforeConcatenation(): void
    {
        $streamA = \gzcompress('q 1 0 0 1 10 20 cm');
        $streamB = \gzcompress('BT /F1 12 Tf ET');
        $this->assertNotFalse($streamA);
        $this->assertNotFalse($streamB);

        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Filter'],
                        ['/', 'FlateDecode'],
                    ],
                ],
                ['stream', $streamA],
            ],
            '2_0' => [
                [
                    '<<',
                    [
                        ['/', 'Filter'],
                        ['/', 'FlateDecode'],
                    ],
                ],
                ['stream', $streamB],
            ],
        ]);
        $cloner = new ResourceCloner(0);

        $combined = $cloner->getContentStream(['Contents' => ['1_0', '2_0']], $src);

        $this->assertSame('', $combined['filter']);
        $this->assertStringContainsString('q 1 0 0 1 10 20 cm', $combined['bytes']);
        $this->assertStringContainsString('BT /F1 12 Tf ET', $combined['bytes']);
    }

    /** @throws \Throwable */
    public function testCloneResourcesPreservesNestedNumericResourceNames(): void
    {
        $src = $this->makeMockSourceDocument([
            '11_0' => [
                ['numeric', 7],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $output = $cloner->cloneResources(
            [
                'ExtGState' => [
                    'GS1' => [
                        9 => '11_0',
                    ],
                ],
            ],
            $src,
            $map,
        );

        $this->assertStringContainsString('/ExtGState << /GS1 << /9 ', $output);
    }

    /** @throws \Throwable */
    public function testGetContentStreamReturnsEmptyResultWhenStreamObjectHasNoBytes(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['not-a-name', 'Filter'],
                        ['/',          'FlateDecode'],
                    ],
                ],
            ],
        ]);
        $cloner = new ResourceCloner(0);

        $result = $cloner->getContentStream(['Contents' => '1_0'], $src);

        $this->assertSame('', $result['bytes']);
        $this->assertSame('', $result['filter']);
        $this->assertSame(0, $result['length']);
    }

    // -------------------------------------------------------------------------
    // enqueueObject — dedup and cycle safety
    // -------------------------------------------------------------------------

    /** @throws \Throwable */
    public function testEnqueueObjectDedupReturnsSameDestNumber(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $num1 = $cloner->enqueueObject('5_0', $src, $map);
        $num2 = $cloner->enqueueObject('5_0', $src, $map);

        // Same source ref must always map to the same destination number.
        $this->assertSame($num1, $num2);
        // pon must be incremented only once.
        $this->assertSame(1, $cloner->getPon());
    }

    /** @throws \Throwable */
    public function testEnqueueObjectDedupAfterFlush(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $num1 = $cloner->enqueueObject('5_0', $src, $map);
        $map->flush();

        // After flushing the queue, the map must still hold the allocation.
        $num2 = $cloner->enqueueObject('5_0', $src, $map);
        $this->assertSame($num1, $num2);
        // No new pon increment.
        $this->assertSame(1, $cloner->getPon());
    }

    /** @throws \Throwable */
    public function testEnqueueObjectForUndefinedRefEmitsNullObject(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        // 99_0 does not exist in the fixture — must get a null placeholder.
        $destNum = $cloner->enqueueObject('99_0', $src, $map);
        $this->assertGreaterThan(0, $destNum);

        $flushed = $map->flush();
        $this->assertStringContainsString($destNum . ' 0 obj', $flushed);
        $this->assertStringContainsString('null', $flushed);
        $this->assertStringContainsString('endobj', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectReturnsAllocatedNumberWhenSourceIsPending(): void
    {
        $src = $this->makeMockSourceDocument([]);
        $map = $this->createStub(ObjectMap::class);
        $cloner = new ResourceCloner(0);

        $map->method('has')->willReturnCallback(static fn(string $srcRef): bool => $srcRef !== '7_0');
        $map->method('isInProgress')->willReturnCallback(static fn(string $srcRef): bool => $srcRef === '7_0');
        $map->method('get')->willReturnCallback(static fn(string $srcRef): int => $srcRef === '7_0' ? 7 : 0);

        $result = $cloner->enqueueObject('7_0', $src, $map);

        $this->assertSame(7, $result);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSerializesStreamObject(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        // Object 4_0 is a stream object in the fixture.
        $destNum = $cloner->enqueueObject('4_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString($destNum . ' 0 obj', $flushed);
        $this->assertStringContainsString('stream', $flushed);
        $this->assertStringContainsString('endstream', $flushed);
        $this->assertStringContainsString('endobj', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectMultipleDistinctRefsIncreasePon(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(5);

        $cloner->enqueueObject('4_0', $src, $map);
        $cloner->enqueueObject('5_0', $src, $map);

        // Each unique ref increments pon once.
        $this->assertSame(7, $cloner->getPon());
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSerializesFirstScalarValueWhenNoDictOrStreamExists(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                ['endobj', ''],
                ['numeric', 123],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $destNum = $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString($destNum . ' 0 obj', $flushed);
        $this->assertStringContainsString("\n123\n", $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSerializesFirstArrayValueWhenScalarObjectContainsArrayToken(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '[',
                    [
                        ['numeric', 1],
                        ['numeric', 2],
                        ['/', 'Name'],
                    ],
                ],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString('[1 2 /Name]', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectScalarObjRefRemapsAndQueuesReferencedObject(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                ['objref', '2 0 R'],
            ],
            '2_0' => [
                ['numeric', 7],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertSame(2, \substr_count($flushed, "endobj\n"));
        $this->assertMatchesRegularExpression('/\d+ 0 R/', $flushed);
        $this->assertStringContainsString("\n7\n", $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectScalarFallbackReturnsNullWhenNoSerializableValueExists(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                ['endobj', ''],
                'junk-token',
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString("\nnull\n", $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSerializesDictionaryValuesAcrossTokenTypes(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Name'],
                        ['string', 'Demo'],
                        ['/', 'Hex'],
                        ['hex', 'CAFE'],
                        ['/', 'Nums'],
                        [
                            '[',
                            [
                                ['numeric', 1],
                                ['numeric', 2],
                            ],
                        ],
                        ['/', 'Ref'],
                        ['objref', '2 0 R'],
                        ['/', 'Kind'],
                        ['/', 'Subtype'],
                    ],
                ],
            ],
            '2_0' => [
                ['numeric', 55],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString('/Name (Demo)', $flushed);
        $this->assertStringContainsString('/Hex <CAFE>', $flushed);
        $this->assertStringContainsString('/Nums [1 2]', $flushed);
        $this->assertStringContainsString('/Kind /Subtype', $flushed);
        $this->assertMatchesRegularExpression('/\/Ref \d+ 0 R/', $flushed);
    }

    /**
     * Regression: tc-lib-pdf-parser tags literal-string dictionary values with
     * the open-paren byte `(` and hex-string values with `<` — NOT the legacy
     * `'string'` / `'hex'` literals that the original `serializeValue()` checked
     * for. Before the fix these values fell through to the bare-scalar path and
     * were emitted without their `( )` / `< >` delimiters, producing malformed
     * PDF dictionaries such as `/FontFamily Futura PT Book` instead of
     * `/FontFamily (Futura PT Book)`. This test mirrors
     * testEnqueueObjectSerializesDictionaryValuesAcrossTokenTypes above but
     * uses the parser's actual token tags.
     *
     * @throws \Throwable
     */
    public function testEnqueueObjectPreservesDelimitersForRealParserTokenTags(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'FontFamily'],
                        ['(', 'Futura PT Book'],
                        ['/', 'CIDSet'],
                        ['<', 'CAFE'],
                    ],
                ],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString('/FontFamily (Futura PT Book)', $flushed);
        $this->assertStringContainsString('/CIDSet <CAFE>', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectDoesNotDoubleEscapeParserLiteralStrings(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Lookup'],
                        ['(', '\\001\\002\\003'],
                    ],
                ],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString('/Lookup (\\001\\002\\003)', $flushed);
        $this->assertStringNotContainsString('/Lookup (\\\\001\\\\002\\\\003)', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSerializesStreamFilterAndSkipsMalformedDictPairs(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Filter'],
                        ['/', 'ASCIIHexDecode'],
                        ['/', 'Length'],
                        ['numeric', 999],
                        ['not-a-name', 'IgnoreMe'],
                        ['numeric', 77],
                        ['/', []],
                        ['string', 'skip'],
                    ],
                ],
                ['stream', 'ABCD'],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString('/Filter /ASCIIHexDecode', $flushed);
        $this->assertStringContainsString('/Length 4', $flushed);
        $this->assertStringNotContainsString('999', $flushed);
        $this->assertStringNotContainsString('IgnoreMe', $flushed);
        $this->assertStringNotContainsString('skip', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSerializesNestedDictionariesAndFallbackTokens(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                [
                    '<<',
                    [
                        ['/', 'Nested'],
                        [
                            '<<',
                            [
                                ['/', 'Flag'],
                                ['numeric', 1],
                            ],
                        ],
                        ['/', 'Literal'],
                        ['token', ['not-scalar']],
                        ['/', 'Unknown'],
                        [null, ['still-not-scalar']],
                    ],
                ],
            ],
            '2_0' => [
                [
                    '[',
                    [
                        5,
                        ['numeric', 2],
                    ],
                ],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $cloner->enqueueObject('2_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString('/Nested << /Flag 1>>', $flushed);
        $this->assertStringContainsString('/Literal token', $flushed);
        $this->assertStringContainsString('/Unknown null', $flushed);
        $this->assertStringContainsString('[5 2]', $flushed);
    }

    /** @throws \Throwable */
    public function testEnqueueObjectSkipsNonArrayEntriesBeforeReturningNullFallback(): void
    {
        $src = $this->makeMockSourceDocument([
            '1_0' => [
                'junk-token',
                123,
                ['endobj', ''],
                ['<<', null],
            ],
        ]);
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        $cloner->enqueueObject('1_0', $src, $map);
        $flushed = $map->flush();

        $this->assertStringContainsString("\nnull\n", $flushed);
    }

    // -------------------------------------------------------------------------
    // Shared resources across multiple importPage() calls (integration-level)
    // -------------------------------------------------------------------------

    /** @throws \Throwable */
    public function testSharedObjectNotDuplicatedInFlushedOutput(): void
    {
        $src = $this->loadFixture();
        $map = new ObjectMap();
        $cloner = new ResourceCloner(0);

        // Simulate two pages sharing font object 5_0.
        $resources = ['Font' => ['F1' => '5_0']];

        // First "page" import: clone resources and flush.
        $cloner->cloneResources($resources, $src, $map);
        $firstFlush = $map->flush();

        // Second "page" import: same shared resource — nothing new should be queued.
        $cloner->cloneResources($resources, $src, $map);
        $secondFlush = $map->flush();

        // First flush must contain 5_0's serialized data.
        $this->assertNotEmpty($firstFlush);
        // Second flush must be empty because 5_0 was already allocated and not re-queued.
        $this->assertSame('', $secondFlush);
    }
}
