<?php

/**
 * This file is part of FPDI
 *
 * @package   setasign\Fpdi
 * @copyright Copyright (c) 2024 Setasign GmbH & Co. KG (https://www.setasign.com)
 * @license   http://opensource.org/licenses/mit-license The MIT License
 */

namespace setasign\Fpdi\PdfParser;

/**
 * A stream reader class
 */
class StreamReader
{
    /**
     * Creates a stream reader instance by a string value.
     *
     * @param string $content
     * @param int $maxMemory
     * @return StreamReader
     */
    public static function createByString($content, $maxMemory = 2097152)
    {
        $h = \fopen('php://temp/maxmemory:' . ((int) $maxMemory), 'r+b');
        \fwrite($h, $content);
        \rewind($h);

        return new self($h, true);
    }

    /**
     * Creates a stream reader instance by a filename.
     *
     * @param string $filename
     * @return StreamReader
     */
    public static function createByFile($filename)
    {
        $h = \fopen($filename, 'rb');
        return new self($h, true);
    }

    /**
     * Defines whether the stream should be closed when the stream reader instance is deconstructed or not.
     *
     * @var bool
     */
    protected $closeStream;

    /**
     * The stream resource.
     *
     * @var resource
     */
    protected $stream;

    /**
     * The byte-offset position in the stream.
     *
     * @var int
     */
    protected $position;

    /**
     * The byte-offset position in the buffer.
     *
     * @var int
     */
    protected $offset;

    /**
     * The buffer length.
     *
     * @var int
     */
    protected $bufferLength;

    /**
     * The total length of the stream.
     *
     * @var int
     */
    protected $totalLength;

    /**
     * The buffer.
     *
     * @var string
     */
    protected $buffer;

    /**
     * StreamReader constructor.
     *
     * @param resource $stream
     * @param bool $closeStream Defines whether to close the stream resource if the instance is destructed or not.
     */
    public function __construct($stream, $closeStream = false)
    {
        if (!\is_resource($stream)) {
            throw new \InvalidArgumentException(
                'No stream given.'
            );
        }

        $metaData = \stream_get_meta_data($stream);
        if (!$metaData['seekable']) {
            throw new \InvalidArgumentException(
                'Given stream is not seekable!'
            );
        }

        if (fseek($stream, 0) === -1) {
            throw new \InvalidArgumentException(
                'Given stream is not seekable!'
            );
        }

        $this->stream = $stream;
        $this->closeStream = $closeStream;
        $this->reset();
    }

    /**
     * The destructor.
     */
    public function __destruct()
    {
        $this->cleanUp();
    }

    /**
     * Closes the file handle.
     */
    public function cleanUp()
    {
        if ($this->closeStream && is_resource($this->stream)) {
            \fclose($this->stream);
        }
    }

    /**
     * Returns the byte length of the buffer.
     *
     * @param bool $atOffset
     * @return int
     */
    public function getBufferLength($atOffset = false)
    {
        if ($atOffset === false) {
            return $this->bufferLength;
        }

        return $this->bufferLength - $this->offset;
    }

    /**
     * Get the current position in the stream.
     *
     * @return int
     */
    public function getPosition()
    {
        return $this->position;
    }

    /**
     * Returns the current buffer.
     *
     * @param bool $atOffset
     * @return string
     */
    public function getBuffer($atOffset = true)
    {
        if ($atOffset === false) {
            return $this->buffer;
        }

        $string = \substr($this->buffer, $this->offset);

        return (string) $string;
    }

    /**
     * Gets a byte at a specific position in the buffer.
     *
     * If the position is invalid the method will return false.
     *
     * If the $position parameter is set to null the value of $this->offset will be used.
     *
     * @param int|null $position
     * @return string|bool
     */
    public function getByte($position = null)
    {
        $position = (int) ($position !== null ? $position : $this->offset);
        if (
            $position >= $this->bufferLength
            && (!$this->increaseLength() || $position >= $this->bufferLength)
        ) {
            return false;
        }

        return $this->buffer[$position];
    }

    /**
     * Returns a byte at a specific position, and set the offset to the next byte position.
     *
     * If the position is invalid the method will return false.
     *
     * If the $position parameter is set to null the value of $this->offset will be used.
     *
     * @param int|null $position
     * @return string|bool
     */
    public function readByte($position = null)
    {
        if ($position !== null) {
            $position = (int) $position;
            // check if needed bytes are available in the current buffer
            if (!($position >= $this->position && $position < $this->position + $this->bufferLength)) {
                $this->reset($position);
                $offset = $this->offset;
            } else {
                $offset = $position - $this->position;
            }
        } else {
            $offset = $this->offset;
        }

        if (
            $offset >= $this->bufferLength
            && ((!$this->increaseLength()) || $offset >= $this->bufferLength)
        ) {
            return false;
        }

        $this->offset = $offset + 1;
        return $this->buffer[$offset];
    }

    /**
     * Read bytes from the current or a specific offset position and set the internal pointer to the next byte.
     *
     * If the position is invalid the method will return false.
     *
     * If the $position parameter is set to null the value of $this->offset will be used.
     *
     * @param int $length
     * @param int|null $position
     * @return string|false
     */
    public function readBytes($length, $position = null)
    {
        $length = (int) $length;
        if ($position !== null) {
            // check if needed bytes are available in the current buffer
            if (!($position >= $this->position && $position < $this->position + $this->bufferLength)) {
                $this->reset($position, $length);
                $offset = $this->offset;
            } else {
                $offset = $position - $this->position;
            }
        } else {
            $offset = $this->offset;
        }

        if (
            ($offset + $length) > $this->bufferLength
            && ((!$this->increaseLength($length)) || ($offset + $length) > $this->bufferLength)
        ) {
            return false;
        }

        $bytes = \substr($this->buffer, $offset, $length);
        $this->offset = $offset + $length;

        return $bytes;
    }

    /**
     * Read a line from the current position.
     *
     * @param int $length
     * @return string|bool
     */
    public function readLine($length = 1024)
    {
        if ($this->ensureContent() === false) {
            return false;
        }

        $line = '';
        while ($this->ensureContent()) {
            $char = $this->readByte();

            if ($char === "\n") {
                break;
            }

            if ($char === "\r") {
                if ($this->getByte() === "\n") {
                    $this->addOffset(1);
                }
                break;
            }

            $line .= $char;

            if (\strlen($line) >= $length) {
                break;
            }
        }

        return $line;
    }

    /**
     * Set the offset position in the current buffer.
     *
     * @param int $offset
     */
    public function setOffset($offset)
    {
        if ($offset > $this->bufferLength || $offset < 0) {
            throw new \OutOfRangeException(
                \sprintf('Offset (%s) out of range (length: %s)', $offset, $this->bufferLength)
            );
        }

        $this->offset = (int) $offset;
    }

    /**
     * Returns the current offset in the current buffer.
     *
     * @return int
     */
    public function getOffset()
    {
        return $this->offset;
    }

    /**
     * Add an offset to the current offset.
     *
     * @param int $offset
     */
    public function addOffset($offset)
    {
        $this->setOffset($this->offset + $offset);
    }

    /**
     * Make sure that there is at least one character beyond the current offset in the buffer.
     *
     * @return bool
     */
    public function ensureContent()
    {
        while ($this->offset >= $this->bufferLength) {
            if (!$this->increaseLength()) {
                return false;
            }
        }
        return true;
    }

    /**
     * Returns the stream.
     *
     * @return resource
     */
    public function getStream()
    {
        return $this->stream;
    }

    /**
     * Gets the total available length.
     *
     * @return int
     */
    public function getTotalLength()
    {
        if ($this->totalLength === null) {
            $stat = \fstat($this->stream);
            $this->totalLength = $stat['size'];
        }

        return $this->totalLength;
    }

    /**
     * Resets the buffer to a position and re-read the buffer with the given length.
     *
     * If the $pos parameter is negative the start buffer position will be the $pos'th position from
     * the end of the file.
     *
     * If the $pos parameter is negative and the absolute value is bigger then the totalLength of
     * the file $pos will set to zero.
     *
     * @param int|null $pos Start position of the new buffer
     * @param int $length Length of the new buffer. Mustn't be negative
     */
    public function reset($pos = 0, $length = 200)
    {
        if ($pos === null) {
            $pos = $this->position + $this->offset;
        } elseif ($pos < 0) {
            $pos = \max(0, $this->getTotalLength() + $pos);
        }

        \fseek($this->stream, $pos);

        $this->position = $pos;
        $this->offset = 0;
        if ($length > 0) {
            $this->buffer = (string) \fread($this->stream, $length);
        } else {
            $this->buffer = '';
        }
        $this->bufferLength = \strlen($this->buffer);

        // If a stream wrapper is in use it is possible that
        // length values > 8096 will be ignored, so use the
        // increaseLength()-method to correct that behavior
        if ($this->bufferLength < $length && $this->increaseLength($length - $this->bufferLength)) {
            // increaseLength parameter is $minLength, so cut to have only the required bytes in the buffer
            $this->buffer = (string) \substr($this->buffer, 0, $length);
            $this->bufferLength = \strlen($this->buffer);
        }
    }

    /**
     * Ensures bytes in the buffer with a specific length and location in the file.
     *
     * @param int $pos
     * @param int $length
     * @see reset()
     */
    public function ensure($pos, $length)
    {
        if (
            $pos >= $this->position
            && $pos < ($this->position + $this->bufferLength)
            && ($this->position + $this->bufferLength) >= ($pos + $length)
        ) {
            $this->offset = $pos - $this->position;
        } else {
            $this->reset($pos, $length);
        }
    }

    /**
     * Forcefully read more data into the buffer.
     *
     * @param int $minLength
     * @return bool Returns false if the stream reaches the end
     */
    public function increaseLength($minLength = 100)
    {
        $length = \max($minLength, 100);

        if (\feof($this->stream) || $this->getTotalLength() === $this->position + $this->bufferLength) {
            return false;
        }

        $newLength = $this->bufferLength + $length;
        do {
            $this->buffer .= \fread($this->stream, $newLength - $this->bufferLength);
            $this->bufferLength = \strlen($this->buffer);
        } while (($this->bufferLength !== $newLength) && !\feof($this->stream));

        return true;
    }
}
