<?php

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

namespace Test;

use Com\Tecnick\File\Exception as FileException;
use Com\Tecnick\Pdf\Encrypt\Encrypt;
use Com\Tecnick\Pdf\Font\Exception as FontException;

/**
 * Output Test
 *
 * @since     2011-05-23
 * @category  Library
 * @package   PdfFont
 * @author    Nicola Asuni <info@tecnick.com>
 * @copyright 2011-2026 Nicola Asuni - Tecnick.com LTD
 * @license   https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE.TXT)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-font
 *
 * @SuppressWarnings("PHPMD.LongVariable")
 *
 * @phpstan-import-type TFontData from \Com\Tecnick\Pdf\Font\Load
 */
class OutputTest extends TestUtil
{
    /** @throws \ReflectionException */
    private function createEncrypt(): Encrypt
    {
        $reflector = new \ReflectionClass(Encrypt::class);
        $encrypt = $reflector->newInstanceWithoutConstructor();

        \assert($encrypt instanceof Encrypt, 'Failed to create Encrypt instance');

        return $encrypt;
    }

    private function prepareTestEnvironment(): void
    {
        parent::setupTest();
    }

    /**
     * @return TFontData
     */
    private function getFontTemplate(): array
    {
        return [
            'Ascender' => 0,
            'Ascent' => 0,
            'AvgWidth' => 0.0,
            'CapHeight' => 0,
            'CharacterSet' => '',
            'Descender' => 0,
            'Descent' => 0,
            'EncodingScheme' => '',
            'FamilyName' => '',
            'Flags' => 0,
            'FontBBox' => [],
            'FontName' => '',
            'FullName' => '',
            'IsFixedPitch' => false,
            'ItalicAngle' => 0,
            'Leading' => 0,
            'MaxWidth' => 0,
            'MissingWidth' => 0,
            'StdHW' => 0,
            'StdVW' => 0,
            'StemH' => 0,
            'StemV' => 0,
            'UnderlinePosition' => 0,
            'UnderlineThickness' => 0,
            'Version' => '',
            'Weight' => '',
            'XHeight' => 0,
            'bbox' => '',
            'cbbox' => [],
            'cidinfo' => ['Ordering' => '', 'Registry' => '', 'Supplement' => 0, 'uni2cid' => []],
            'compress' => false,
            'ctg' => '',
            'ctgdata' => [],
            'cw' => [],
            'cwu' => [],
            'datafile' => '',
            'desc' => [
                'Ascent' => 0,
                'AvgWidth' => 0,
                'CapHeight' => 0,
                'Descent' => 0,
                'Flags' => 0,
                'FontBBox' => '',
                'ItalicAngle' => 0,
                'Leading' => 0,
                'MaxWidth' => 0,
                'MissingWidth' => 0,
                'StemH' => 0,
                'StemV' => 0,
                'XHeight' => 0,
            ],
            'diff' => '',
            'diff_n' => 0,
            'dir' => '',
            'dw' => 0,
            'enc' => '',
            'enc_map' => [],
            'encodingTables' => [],
            'encoding_id' => 0,
            'encrypted' => '',
            'fakestyle' => false,
            'family' => '',
            'file' => '',
            'file_n' => 0,
            'file_name' => '',
            'i' => 0,
            'ifile' => '',
            'indexToLoc' => [],
            'input_file' => '',
            'isUnicode' => false,
            'italicAngle' => 0,
            'key' => '',
            'lenIV' => 0,
            'length1' => 0,
            'length2' => 0,
            'linked' => false,
            'mode' => [
                'bold' => false,
                'italic' => false,
                'linethrough' => false,
                'overline' => false,
                'underline' => false,
            ],
            'n' => 0,
            'name' => '',
            'numGlyphs' => 0,
            'numHMetrics' => 0,
            'originalsize' => 0,
            'pdfa' => false,
            'platform_id' => 0,
            'settype' => '',
            'short_offset' => false,
            'size1' => 0,
            'size2' => 0,
            'style' => '',
            'subset' => false,
            'subsetchars' => [],
            'table' => [],
            'tot_num_glyphs' => 0,
            'type' => '',
            'underlinePosition' => 0,
            'underlineThickness' => 0,
            'unicode' => false,
            'unitsPerEm' => 0,
            'up' => 0,
            'urk' => 0.0,
            'ut' => 0,
            'weight' => '',
        ];
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \RangeException
     * @throws \ReflectionException
     */
    public function testOutput(): void
    {
        $this->prepareTestEnvironment();
        $indir = \dirname(__DIR__) . '/util/vendor/tecnickcom/tc-font-mirror/';

        $objnum = 1;
        $stack = new \Com\Tecnick\Pdf\Font\Stack(1);

        new \Com\Tecnick\Pdf\Font\Import($indir . 'pdfa/pfb/PDFASymbol.pfb', '', 'Type1', 'symbol');
        $stack->add($objnum, 'pdfasymbol');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'core/Helvetica.afm');
        $stack->add($objnum, 'helvetica');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'core/Helvetica-Bold.afm');
        $stack->add($objnum, 'helvetica', 'B');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'core/Helvetica-BoldOblique.afm');
        $stack->add($objnum, 'helveticaBI');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'core/Helvetica-Oblique.afm');
        $stack->add($objnum, 'helvetica', 'I');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSans.ttf');
        $stack->add($objnum, 'freesans', '');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSansBold.ttf');
        $stack->add($objnum, 'freesans', 'B');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSansOblique.ttf');
        $stack->add($objnum, 'freesans', 'I');

        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSansBoldOblique.ttf');
        $stack->add($objnum, 'freesans', 'BIUDO', '', true);

        new \Com\Tecnick\Pdf\Font\Import($indir . 'cid0/cid0jp.ttf', '', 'CID0JP');
        $stack->add($objnum, 'cid0jp');

        $fonts = $stack->getFonts();
        $this->assertCount(10, $fonts);

        $encrypt = $this->createEncrypt();
        $output = new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null);

        $this->assertEquals(37, $output->getObjectNumber());

        $this->assertNotEmpty($output->getFontsBlock());

        $this->assertNotEmpty($output->getOutFontDict());

        $keys = [];
        foreach ($fonts as $font) {
            $keys[] = $font['key'];
        }

        $this->assertNotEmpty($output->getOutFontDictByKeys($keys));
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \ReflectionException
     */
    public function testOutputWithNoFontsReturnsEmptyStrings(): void
    {
        // Empty font array: constructor still runs without error; all output methods
        // return empty strings because there is nothing to iterate over.
        $encrypt = $this->createEncrypt();
        $output = new \Com\Tecnick\Pdf\Font\Output([], 1, $encrypt, null);

        $this->assertSame('', $output->getFontsBlock());
        $this->assertSame('', $output->getOutFontDict());
        $this->assertSame('', $output->getOutFontDictByKeys([]));
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \ReflectionException
     */
    public function testOutputGetFontDefinitionsThrowsOnUnknownFontType(): void
    {
        // A font entry with an unrecognised type triggers the default branch of the
        // match expression inside getFontDefinitions, which throws FontException.
        $this->bcExpectException(\Com\Tecnick\Pdf\Font\Exception::class);

        $encrypt = $this->createEncrypt();

        // Build a minimal font array with an unknown type so that getFontDefinitions
        // reaches the default throw branch.
        $fonts = ['unknown_key' => $this->getFontTemplate()];
        $fonts['unknown_key']['type'] = 'UnknownType';

        new \Com\Tecnick\Pdf\Font\Output($fonts, 1, $encrypt, null);
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \RangeException
     * @throws \ReflectionException
     */
    public function testSubsetTrueTypeUnicodeOutputUsesValidCidSystemInfoAndFontStream(): void
    {
        $this->prepareTestEnvironment();
        $indir = \dirname(__DIR__) . '/util/vendor/tecnickcom/tc-font-mirror/';

        $objnum = 1;
        $stack = new \Com\Tecnick\Pdf\Font\Stack(1);
        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSans.ttf');
        $stack->add($objnum, 'freesans', '', '', true);

        // Ensure at least a few glyphs are included in the subset.
        foreach ([32, 65, 66, 67, 937, 960] as $ord) {
            $stack->addSubsetChar('freesans', $ord);
        }

        $encrypt = $this->createEncrypt();
        $output = new \Com\Tecnick\Pdf\Font\Output($stack->getFonts(), $objnum, $encrypt, null);
        $out = $output->getFontsBlock();

        $this->assertStringNotContainsString('/Registry () /Ordering ()', $out);
        $this->assertStringContainsString('/Registry (Adobe) /Ordering (Identity) /Supplement 0', $out);

        $matches = [];
        \preg_match_all('/\\/Length1\\s+(\\d+)/', $out, $matches);
        $lengthMatches = $matches[1] ?? [];
        $lengths = \array_map('intval', $lengthMatches);
        $this->assertNotEmpty($lengths);
        $this->assertGreaterThan(1000, \max($lengths));
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \RangeException
     * @throws \ReflectionException
     */
    public function testSubsetCharMergePreservesUnicodeKeys(): void
    {
        $this->prepareTestEnvironment();
        $indir = \dirname(__DIR__) . '/util/vendor/tecnickcom/tc-font-mirror/';

        $objnum = 1;
        $stack = new \Com\Tecnick\Pdf\Font\Stack(1);
        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSans.ttf');
        $stack->add($objnum, 'freesans', '', '', true);

        $fonts = $stack->getFonts();
        if (!isset($fonts['freesans'])) {
            $this->fail('Expected freesans font data');
        }

        $base = $fonts['freesans'];
        $base['key'] = 'freesans_dup';
        $base['i'] += 1000;
        $base['n'] += 1000;
        $base['subsetchars'] = [8776 => true, 9999 => false];
        $primary = $fonts['freesans'];
        $primary['subsetchars'] = [960 => true];

        $fonts = \array_replace($fonts, [
            'freesans' => $primary,
            'freesans_dup' => $base,
        ]);

        $encrypt = $this->createEncrypt();
        $output = new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null);

        $ref = new \ReflectionClass($output);
        $prop = $ref->getProperty('subchars');
        /** @var array<int, array<int, bool>> $subchars */
        $subchars = $prop->getValue($output);

        $this->assertIsArray($subchars);
        $this->assertNotEmpty($subchars);
        $first = \array_values($subchars)[0] ?? null;
        $this->assertIsArray($first);
        $this->assertArrayHasKey(960, $first);
        $this->assertArrayHasKey(8776, $first);
        $this->assertArrayNotHasKey(9999, $first);
    }

    public function testUniToCidPreservesNumericCidKeys(): void
    {
        $outfont = new OutputTestOutFont();

        $font = $this->getFontTemplate();
        $font['cidinfo'] = [
            'Ordering' => 'Identity',
            'Registry' => 'Adobe',
            'Supplement' => 0,
            'uni2cid' => [960 => 853, 8776 => 3283],
        ];
        $font['cw'] = [32 => 250, 960 => 500, 8776 => 600];
        $font['i'] = 1;
        $font['n'] = 1;
        $font['name'] = 'test';
        $font['subset'] = true;

        $outfont->runUniToCid($font, 0);

        $this->assertArrayHasKey(853, $font['cw']);
        $this->assertArrayHasKey(3283, $font['cw']);
        $this->assertSame(500, $font['cw'][853] ?? null);
        $this->assertSame(600, $font['cw'][3283] ?? null);
    }

    /** @throws \Com\Tecnick\Pdf\Font\Exception */
    public function testGetFontFullPathThrowsForMissingFile(): void
    {
        $this->setupTest();
        $outfont = new OutputTestOutFont();
        $this->bcExpectException(\Com\Tecnick\Pdf\Font\Exception::class);
        $outfont->runGetFontFullPath($this->getFontPath(), 'not-here.bin');
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \ReflectionException
     */
    public function testOutputRejectsSubsetFromPlainFileData(): void
    {
        $this->prepareTestEnvironment();
        $this->bcExpectException(\Com\Tecnick\Pdf\Font\Exception::class);

        $tmpfile = $this->getFontPath() . 'plain-font.bin';
        \file_put_contents($tmpfile, 'not-gzip-data');

        $font = $this->getFontTemplate();
        $font['key'] = 'plain';
        $font['name'] = 'Plain';
        $font['i'] = 1;
        $font['n'] = 1;
        $font['file'] = 'plain-font.bin';
        $font['dir'] = $this->getFontPath();
        $font['subset'] = true;
        $font['subsetchars'] = [65 => true];

        $encrypt = $this->createEncrypt();
        \set_error_handler(static fn(): bool => true);
        try {
            new \Com\Tecnick\Pdf\Font\Output(['plain' => $font], 1, $encrypt, null);
        } finally {
            \restore_error_handler();
        }
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \ReflectionException
     */
    public function testOutputBuildsTrueTypeDefinitionWithDefaultEncoding(): void
    {
        $font = $this->getFontTemplate();
        $font['key'] = 'truetypefont';
        $font['name'] = 'TrueTypeFont';
        $font['type'] = 'TrueType';
        $font['i'] = 1;
        $font['n'] = 1;
        $font['enc'] = 'cp1252';
        $font['dw'] = 600;
        $font['cw'] = [32 => 250, 65 => 700];

        $encrypt = $this->createEncrypt();
        $output = new \Com\Tecnick\Pdf\Font\Output(['truetypefont' => $font], 1, $encrypt, null);
        $block = $output->getFontsBlock();

        $this->assertStringContainsString('/Subtype /TrueType', $block);
        $this->assertStringContainsString('/Encoding /WinAnsiEncoding', $block);
    }

    public function testGetKeyValOutFormatsFloatValues(): void
    {
        $outfont = new OutputTestOutFont();
        $out = $outfont->runGetKeyValOut('ItalicAngle', 12.5);
        $this->assertSame(' /ItalicAngle 12.500000', $out);
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \ReflectionException
     */
    public function testOutputBuildsCidFont0WhenGlyphOneIsNotDefined(): void
    {
        $font = $this->getFontTemplate();
        $font['key'] = 'cidfont0';
        $font['name'] = 'CIDFont0Test';
        $font['type'] = 'CIDFont0';
        $font['i'] = 1;
        $font['n'] = 1;
        $font['enc'] = 'Identity-H';
        $font['dw'] = 600;
        $font['cw'] = [32 => 500, 65 => 700];
        $font['cidinfo'] = [
            'Registry' => 'Adobe',
            'Ordering' => 'Identity',
            'Supplement' => 0,
            'uni2cid' => [],
        ];

        $encrypt = $this->createEncrypt();
        $output = new \Com\Tecnick\Pdf\Font\Output(['cidfont0' => $font], 1, $encrypt, null);
        $block = $output->getFontsBlock();

        $this->assertStringContainsString('/Subtype /Type0', $block);
        $this->assertStringContainsString('/Subtype /CIDFontType0', $block);
    }

    /**
     * Build a subset-enabled FreeSans font stack for cache tests.
     *
     * @return array<string, TFontData>
     *
     * @throws FileException
     * @throws FontException
     * @throws \RangeException
     */
    private function buildSubsetFonts(int $objnum): array
    {
        $this->prepareTestEnvironment();
        $indir = \dirname(__DIR__) . '/util/vendor/tecnickcom/tc-font-mirror/';

        $stack = new \Com\Tecnick\Pdf\Font\Stack(1);
        new \Com\Tecnick\Pdf\Font\Import($indir . 'freefont/FreeSans.ttf');
        $stack->add($objnum, 'freesans', '', '', true);

        foreach ([32, 65, 66, 67, 937, 960] as $ord) {
            $stack->addSubsetChar('freesans', $ord);
        }

        return $stack->getFonts();
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \RangeException
     * @throws \ReflectionException
     */
    public function testSubsetCacheStoresOnMissAndReusesOnHit(): void
    {
        $objnum = 1;
        $fonts = $this->buildSubsetFonts($objnum);
        $cache = new SpyFontSubsetCache();
        $encrypt = $this->createEncrypt();

        $firstObj = new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null, $cache);
        $first = $firstObj->getFontsBlock();

        $secondObj = new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null, $cache);
        $second = $secondObj->getFontsBlock();

        $this->assertSame($first, $second);
        $this->assertCount(1, $cache->store, 'subset should be stored exactly once');

        $storedKey = \array_key_first($cache->store);
        $this->assertIsString($storedKey);
        $this->assertSame([$storedKey], $cache->setCalls, 'subset should be computed and stored exactly once');
        $this->assertSame(
            [$storedKey, $storedKey],
            $cache->getCalls,
            'cache should be consulted on every pass (miss then hit) for the same key',
        );
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \RangeException
     * @throws \ReflectionException
     */
    public function testSubsetCacheHitProducesIdenticalOutputToNoCache(): void
    {
        $objnum = 1;
        $fonts = $this->buildSubsetFonts($objnum);
        $encrypt = $this->createEncrypt();

        $noCacheObj = new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null, null);
        $noCache = $noCacheObj->getFontsBlock();

        $cache = new SpyFontSubsetCache();
        // First pass populates the cache (miss), second pass reads from it (hit).
        new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null, $cache);
        $hitObj = new \Com\Tecnick\Pdf\Font\Output($fonts, $objnum, $encrypt, null, $cache);
        $hit = $hitObj->getFontsBlock();

        $this->assertSame($noCache, $hit, 'a cache hit must reproduce the no-cache output byte-for-byte');
    }

    /**
     * @throws FileException
     * @throws FontException
     * @throws \ReflectionException
     */
    public function testSubsetCacheKeyIsStableAndDiscriminating(): void
    {
        $this->setupTest();
        $encrypt = $this->createEncrypt();
        $output = new OutputTestOutput([], 1, $encrypt, null);

        $font = $this->getFontTemplate();
        $font['platform_id'] = 3;
        $font['encoding_id'] = 1;
        $font['type'] = 'TrueTypeUnicode';
        $data = 'FONT-PROGRAM-BYTES';

        $keyA = $output->runSubsetCacheKey($data, $font, [65 => true, 66 => true]);

        $keyOrder = $output->runSubsetCacheKey($data, $font, [66 => true, 65 => true]);
        $this->assertSame($keyA, $keyOrder, 'char insertion order must not change the key');

        $keyDisabled = $output->runSubsetCacheKey($data, $font, [65 => true, 66 => true, 67 => false]);
        $this->assertSame($keyA, $keyDisabled, 'disabled chars must not change the key');

        $keyChars = $output->runSubsetCacheKey($data, $font, [65 => true]);
        $this->assertNotSame($keyA, $keyChars, 'a different char set must change the key');

        $keyBytes = $output->runSubsetCacheKey('OTHER-BYTES', $font, [65 => true, 66 => true]);
        $this->assertNotSame($keyA, $keyBytes, 'different font bytes must change the key');

        $fontAlt = $font;
        $fontAlt['encoding_id'] = 10;
        $keyCmap = $output->runSubsetCacheKey($data, $fontAlt, [65 => true, 66 => true]);
        $this->assertNotSame($keyA, $keyCmap, 'different cmap selection metrics must change the key');
    }
}
