<?php

declare(strict_types=1);

/**
 * Output.php
 *
 * @since     2011-05-23
 * @category  Library
 * @package   PdfImage
 * @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)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-image
 *
 * This file is part of tc-lib-pdf-image software library.
 */

namespace Com\Tecnick\Pdf\Image;

use Com\Tecnick\File\File as ObjFile;
use Com\Tecnick\Pdf\Encrypt\Encrypt;
use Com\Tecnick\Pdf\Image\Exception as ImageException;

/**
 * Com\Tecnick\Pdf\Image\Output
 *
 * @since     2011-05-23
 * @category  Library
 * @package   PdfImage
 * @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)
 * @link      https://github.com/tecnickcom/tc-lib-pdf-image
 *
 * @phpstan-type TFileOptions array{
 *   allowedHosts?: array<string>,
 *   maxRemoteSize?: int,
 *   curlopts?: array<int, bool|int|string>,
 *   defaultCurlOpts?: array<int, bool|int|string>,
 *   fixedCurlOpts?: array<int, bool|int|string>
 * }
 *
 * @phpstan-import-type ImageBaseData from \Com\Tecnick\Pdf\Image\Import
 * @phpstan-import-type ImageRawData from \Com\Tecnick\Pdf\Image\Import
 *
 * @SuppressWarnings("PHPMD.ExcessiveClassComplexity")
 */
abstract class Output
{
    /**
     * Current PDF object number.
     */
    protected int $pon;

    /**
     * Store image object IDs for the XObject Dictionary.
     *
     * @var array<string, int>
     */
    protected array $xobjdict = [];

    /**
     * Stack of added images.
     *
     * @var array<int, array{
     *          'iid': int,
     *          'key': string,
     *          'width': int,
     *          'height': int,
     *          'defprint': bool,
     *          'altimgs'?: array<int, int>,
     *      }>
     */
    protected array $image = [];

    /**
     * Cache used to store imported image data.
     * The same image data can be reused multiple times.
     *
     * @var array<string, ImageRawData>
     */
    protected array $cache = [];

    /**
     * Initialize images data.
     *
     * @param float                  $kunit      Unit of measure conversion ratio.
     * @param Encrypt                 $encrypt    Encrypt object.
     * @param ObjFile                 $fileHelper File helper for image loading and writing.
     * @param bool                    $pdfa       True if we are in PDF/A mode.
     * @param bool                    $compress   Set to false to disable stream compression.
     * @param ?ImageCacheInterface    $imageCache Optional external cache to persist processed image
     *                                            data across instances and processes (null = disabled).
     */
    public function __construct(
        protected float $kunit,
        protected Encrypt $encrypt,
        protected ObjFile $fileHelper,
        protected bool $pdfa = false,
        protected bool $compress = true,
        protected ?ImageCacheInterface $imageCache = null,
    ) {
        $this->fileHelper = $fileHelper;
    }

    /**
     * Returns current PDF object number.
     */
    public function getObjectNumber(): int
    {
        return $this->pon;
    }

    /**
     * Run a callback with a temporary file helper.
     *
     * @template TResult
     *
     * @param ObjFile $fileHelper Temporary file helper to use while the callback executes.
     * @param callable(): TResult $callback Callback executed with the temporary file helper.
     *
     * @return TResult
     */
    public function withFileHelper(ObjFile $fileHelper, callable $callback): mixed
    {
        $previousFileHelper = $this->fileHelper;
        $this->fileHelper = $fileHelper;

        try {
            return $callback();
        } finally {
            $this->fileHelper = $previousFileHelper;
        }
    }

    /**
     * Get the PDF output string to print the specified image ID.
     *
     * @param int   $iid        Image ID.
     * @param float $xpos       Abscissa (X coordinate) of the upper-left Image corner in user units.
     * @param float $ypos       Ordinate (Y coordinate) of the upper-left Image corner in user units.
     * @param float $width      Image width in user units.
     * @param float $height     Image height in user units.
     * @param float $pageheight Page height in user units.
     *
     * @return string Image PDF page content.
     *
     * @throws \Com\Tecnick\Pdf\Image\Exception If the image ID is not found.
     */
    public function getSetImage(
        int $iid,
        float $xpos,
        float $ypos,
        float $width,
        float $height,
        float $pageheight,
    ): string {
        $img = $this->image[$iid] ?? null;
        if ($img === null) {
            throw new ImageException('Unknown image ID: ' . $iid);
        }

        $out = 'q';
        $out .= \sprintf(
            ' %F 0 0 %F %F %F cm',
            $width * $this->kunit,
            $height * $this->kunit,
            $xpos * $this->kunit,
            ($pageheight - $ypos - $height) * $this->kunit, // reverse coordinate
        );

        if (!isset($this->cache[$img['key']]['mask'])) {
            return $out . ' /IMG' . $iid . ' Do Q' . "\n";
        }

        if (!isset($this->cache[$img['key']]['plain'])) {
            return $out . ' /IMGmask' . $iid . ' Do Q' . "\n";
        }

        return $out . ' /IMGplain' . $iid . ' Do Q' . "\n";
    }

    /**
     * Get the PDF output string for Images.
     *
     * @param int $pon Current PDF Object Number.
     *
     * @return string PDF code for the images block.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception If image object encryption fails.
     */
    public function getOutImagesBlock(int $pon): string
    {
        $this->pon = $pon;
        $out = '';
        foreach ($this->image as $iid => $img) {
            if (!isset($this->cache[$img['key']]['out'])) {
                if (isset($this->cache[$img['key']]['mask'])) {
                    /** @var ImageRawData $mask */
                    $mask = &$this->cache[$img['key']]['mask'];
                    $out .= $this->getOutImage($img, $mask, 'mask');
                    if (isset($this->cache[$img['key']]['plain'])) {
                        /** @var ImageRawData $plain */
                        $plain = &$this->cache[$img['key']]['plain'];
                        $out .= $this->getOutImage($img, $plain, 'plain');
                    }
                }

                if (!isset($this->cache[$img['key']]['mask'])) {
                    $out .= $this->getOutImage($img, $this->cache[$img['key']]);
                }

                unset($mask, $plain);

                $this->image[$iid] = $img;
            }

            if (($this->cache[$img['key']]['mask']['obj'] ?? 0) === 0) {
                $this->xobjdict['IMG' . $img['iid']] = $this->cache[$img['key']]['obj'];
            } else {
                $maskData = $this->cache[$img['key']]['mask'] ?? null;
                if ($maskData === null) {
                    $this->xobjdict['IMG' . $img['iid']] = $this->cache[$img['key']]['obj'];
                    continue;
                }

                $plainData = $this->cache[$img['key']]['plain'] ?? null;
                if (($plainData['obj'] ?? 0) === 0) {
                    $this->xobjdict['IMGmask' . $img['iid']] = (int) $maskData['obj'];
                } elseif ($plainData !== null) {
                    $this->xobjdict['IMGplain' . $img['iid']] = (int) $plainData['obj'];
                }
            }
        }

        return $out;
    }

    /**
     * Get the PDF output string for Image object.
     *
     * @param array{
     *          'iid': int,
     *          'key': string,
     *          'width': int,
     *          'height': int,
     *          'defprint': bool,
     *          'altimgs'?: array<int, int>,
     *      }  $img  Image reference.
     * @param ImageRawData  $data Image raw data.
     * @param string $sub  Sub image ('mask', 'plain' or empty string).
     *
     * @return string PDF Image object.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception If image data encryption fails.
     */
    protected function getOutImage(array &$img, array &$data, string $sub = ''): string
    {
        $out = $this->getOutIcc($data) . $this->getOutPalette($data) . $this->getOutAltImages($img, $data, $sub);

        $data['obj'] = ++$this->pon;

        $out .=
            $data['obj']
            . ' 0 obj'
            . "\n"
            . '<<'
            . ' /Type /XObject'
            . ' /Subtype /Image'
            . ' /Width '
            . $data['width']
            . ' /Height '
            . $data['height']
            . $this->getOutColorInfo($data);

        if ($data['exturl']) {
            // external stream
            $out .=
                ' /Length 0 /F'
                . ' <<'
                . ' /FS /URL /F '
                . $this->encrypt->escapeDataString('true', $this->pon)
                . ' >>';
            if ($data['filter'] !== '') {
                $out .= ' /FFilter /' . $data['filter'];
            }

            $out .= ' >>' . "\n";
        } else {
            if ($data['filter'] !== '') {
                $out .= ' /Filter /' . $data['filter'];
            }

            if ($data['parms'] !== '') {
                $out .= ' ' . $data['parms'];
            }

            // Colour Key Masking
            if ($data['trns'] !== []) {
                $trns = $this->getOutTransparency($data);
                if ($trns !== '') {
                    $out .= ' /Mask [ ' . $trns . ']';
                }
            }

            $stream = $this->encrypt->encryptString($data['data'], $this->pon);
            $out .= ' /Length ' . \strlen($stream) . '>> stream' . "\n" . $stream . "\n" . 'endstream' . "\n";
        }

        $out .= 'endobj' . "\n";

        $this->cache[$img['key']]['out'] = true; // mark this as done

        return $out;
    }

    /**
     * Return XObjects Dictionary portion for the images.
     */
    public function getXobjectDict(): string
    {
        $out = '';
        foreach ($this->xobjdict as $iid => $objid) {
            $out .= ' /' . $iid . ' ' . $objid . ' 0 R';
        }

        return $out;
    }

    /**
     * Return XObjects Dictionary.
     *
     * @param array<int> $keys Image IDs.
     */
    public function getXobjectDictByKeys(array $keys): string
    {
        if ($keys === []) {
            return '';
        }

        $out = '';

        foreach ($keys as $iid) {
            $key = 'IMG' . $iid;
            if (isset($this->xobjdict[$key])) {
                $out .= ' /' . $key . ' ' . $this->xobjdict[$key] . ' 0 R';
                continue;
            }
            $key = 'IMGplain' . $iid;
            if (isset($this->xobjdict[$key])) {
                $out .= ' /' . $key . ' ' . $this->xobjdict[$key] . ' 0 R';
                continue;
            }
            $key = 'IMGmask' . $iid;
            if (isset($this->xobjdict[$key])) {
                $out .= ' /' . $key . ' ' . $this->xobjdict[$key] . ' 0 R';
                continue;
            }
        }

        return $out;
    }

    /**
     * Get the PDF output string for ICC object.
     *
     * @param ImageRawData $data Image raw data.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception If ICC profile encryption fails.
     */
    protected function getOutIcc(array &$data): string
    {
        if ($data['icc'] === '') {
            return '';
        }

        $data['obj_icc'] = ++$this->pon;
        $out =
            $data['obj_icc']
            . ' 0 obj'
            . "\n"
            . '<<'
            . ' /N '
            . $data['channels']
            . ' /Alternate /'
            . $data['colspace'];
        $icc = $data['icc'];
        if ($this->compress) {
            $out .= ' /Filter /FlateDecode';
            $cicc = \gzcompress($icc);
            if ($cicc !== false) {
                $icc = $cicc;
            }
        }

        $stream = $this->encrypt->encryptString($icc, $this->pon);
        return (
            $out . (
                ' /Length '
                . \strlen($stream)
                . ' >>'
                . ' stream'
                . "\n"
                . $stream
                . "\n"
                . 'endstream'
                . "\n"
                . 'endobj'
                . "\n"
            )
        );
    }

    /**
     * Get the PDF output string for Indexed palette object.
     *
     * @param ImageRawData $data Image raw data.
     *
     * @throws \Com\Tecnick\Pdf\Encrypt\Exception If palette encryption fails.
     */
    protected function getOutPalette(array &$data): string
    {
        if ($data['colspace'] !== 'Indexed') {
            return '';
        }

        $data['obj_pal'] = ++$this->pon;
        $out = $data['obj_pal'] . ' 0 obj' . "\n" . '<<';
        $pal = $data['pal'];
        if ($this->compress) {
            $out .= '/Filter /FlateDecode';
            $cpal = \gzcompress($pal);
            if ($cpal !== false) {
                $pal = $cpal;
            }
        }

        $stream = $this->encrypt->encryptString($pal, $this->pon);
        return (
            $out . (
                ' /Length '
                . \strlen($stream)
                . '>>'
                . ' stream'
                . "\n"
                . $stream
                . "\n"
                . 'endstream'
                . "\n"
                . 'endobj'
                . "\n"
            )
        );
    }

    /**
     * Get the PDF output string for color and mask information.
     *
     * @param ImageRawData $data Image raw data.
     */
    protected function getOutColorInfo(array $data): string
    {
        $out = '';
        // set color space
        if ($data['obj_icc'] !== 0) {
            // ICC Colour Space
            $out .= ' /ColorSpace [/ICCBased ' . $data['obj_icc'] . ' 0 R]';
        } elseif ($data['obj_pal'] !== 0) {
            // Indexed Colour Space
            $out .=
                ' /ColorSpace [/Indexed /DeviceRGB '
                . ((\strlen($data['pal']) / 3) - 1)
                . ' '
                . $data['obj_pal']
                . ' 0 R]';
        } else {
            // Device Colour Space
            $out .= ' /ColorSpace /' . $data['colspace'];
        }

        if ($data['colspace'] === 'DeviceCMYK') {
            $out .= ' /Decode [1 0 1 0 1 0 1 0]';
        }

        $out .= ' /BitsPerComponent ' . $data['bits'];

        $maskobj = (int) ($this->cache[$data['key']]['mask']['obj'] ?? 0);
        if (!$data['ismask'] && $maskobj > 0) {
            $out .= ' /SMask ' . $maskobj . ' 0 R';
        }

        if ($data['obj_alt'] !== 0) {
            // reference to alternate images dictionary
            $out .= ' /Alternates ' . $data['obj_alt'] . ' 0 R';
        }

        return $out;
    }

    /**
     * Get the PDF output string for Alternate images object.
     *
     * @param array{
     *          'iid': int,
     *          'key': string,
     *          'width': int,
     *          'height': int,
     *          'defprint': bool,
     *          'altimgs'?: array<int, int>,
     *      } $img Image reference.
     * @param ImageRawData $data Image raw data.
     * @param string $sub Sub image ('mask', 'plain' or empty string).
     */
    protected function getOutAltImages(array $img, array &$data, string $sub = ''): string
    {
        if ($this->pdfa || !isset($img['altimgs']) || $img['altimgs'] === [] || $sub === 'mask') {
            return '';
        }

        $data['obj_alt'] = ++$this->pon;

        $out = $this->pon . ' 0 obj' . "\n" . '[';
        foreach ($img['altimgs'] as $iid) {
            $altimg = $this->image[$iid] ?? null;
            if ($altimg === null) {
                continue;
            }

            $altobj = (int) ($this->cache[$altimg['key']]['obj'] ?? 0);
            if ($altobj === 0) {
                continue;
            }

            $out .=
                ' << /Image '
                . $altobj
                . ' 0 R'
                . ' /DefaultForPrinting '
                . ($altimg['defprint'] ? 'true' : 'false')
                . ' >>';
        }

        return $out . (' ]' . "\n" . 'endobj' . "\n");
    }

    /**
     * Get the PDF output string for color transparency.
     *
     * @param ImageRawData $data Image raw data.
     */
    protected function getOutTransparency(array $data): string
    {
        $trns = '';

        if ($data['colspace'] === 'Indexed') {
            // Indexed: trns holds one alpha byte per palette entry; mask the
            // fully-transparent entries (alpha 0) by their palette index.
            foreach ($data['trns'] as $idx => $val) {
                if ($val !== 0) {
                    continue;
                }

                $trns .= $idx . ' ' . $idx . ' ';
            }

            return $trns;
        }

        // DeviceRGB / DeviceGray: trns holds the transparent colour sample
        // values themselves, emitted as single-value colour-key ranges.
        foreach ($data['trns'] as $val) {
            $trns .= $val . ' ' . $val . ' ';
        }

        return $trns;
    }
}
