<?php
/**
 * This file is part of the ZBateson\StreamDecorators project.
 *
 * @license http://opensource.org/licenses/bsd-license.php BSD
 */

namespace ZBateson\StreamDecorators;

use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;

/**
 * Maintains an internal 'read' position, and seeks to it before reading, then
 * seeks back to the original position of the underlying stream after reading if
 * the attached stream supports seeking.
 *
 * Although copied form LimitStream, it's not inherited from it since $offset
 * and $limit are set to private on LimitStream, and most other functions are
 * re-implemented anyway.  This also decouples the implementation from upstream
 * changes.
 */
class SeekingLimitStream implements StreamInterface
{
    use StreamDecoratorTrait;

    /** @var int Offset to start reading from */
    private int $offset;

    /** @var int Limit the number of bytes that can be read */
    private int $limit;

    /**
     * @var int Number of bytes written, and importantly, if non-zero, writes a
     *      final $lineEnding on close (and so maintained instead of using
     *      tell() directly)
     */
    private int $position = 0;

    /**
     * @param StreamInterface $stream Stream to wrap
     * @param int             $limit  Total number of bytes to allow to be read
     *                                from the stream. Pass -1 for no limit.
     * @param int             $offset Position to seek to before reading (only
     *                                works on seekable streams).
     */
    public function __construct(private readonly StreamInterface $stream, int $limit = -1, int $offset = 0)
    {
        $this->setLimit($limit);
        $this->setOffset($offset);
    }

    /**
     * Returns the current relative read position of this stream subset.
     */
    public function tell() : int
    {
        return $this->position;
    }

    /**
     * Returns the size of the limited subset of data, or null if the wrapped
     * stream returns null for getSize.
     */
    public function getSize() : ?int
    {
        $size = $this->stream->getSize();
        if ($size === null) {
            // this shouldn't happen on a seekable stream I don't think...
            $pos = $this->stream->tell();
            $this->stream->seek(0, SEEK_END);
            $size = $this->stream->tell();
            $this->stream->seek($pos);
        }
        if ($this->limit === -1) {
            return $size - $this->offset;
        }

        return \min([$this->limit, $size - $this->offset]);
    }

    /**
     * Returns true if the current read position is at the end of the limited
     * stream
     */
    public function eof() : bool
    {
        $size = $this->limit;
        if ($size === -1) {
            $size = $this->getSize();
        }
        return ($this->position >= $size);
    }

    /**
     * Ensures the seek position specified is within the stream's bounds, and
     * sets the internal position pointer (doesn't actually seek).
     */
    private function doSeek(int $pos) : void
    {
        if ($this->limit !== -1) {
            $pos = \min([$pos, $this->limit]);
        }
        $this->position = \max([0, $pos]);
    }

    /**
     * Seeks to the passed position within the confines of the limited stream's
     * bounds.
     *
     * For SeekingLimitStream, no actual seek is performed on the underlying
     * wrapped stream.  Instead, an internal pointer is set, and the stream is
     * 'seeked' on read operations
     *
     * @param int $offset
     * @param int $whence
     */
    public function seek($offset, $whence = SEEK_SET) : void
    {
        $pos = match ($whence) {
            SEEK_CUR => $this->position + $offset,
            SEEK_END => $this->limit + $offset,
            default => $offset,
        };
        $this->doSeek($pos);
    }

    /**
     * Sets the offset to start reading from the wrapped stream.
     */
    public function setOffset(int $offset) : void
    {
        $this->offset = $offset;
        $this->position = 0;
    }

    /**
     * Sets the length of the stream to the passed $limit.
     */
    public function setLimit(int $limit) : void
    {
        $this->limit = $limit;
    }

    /**
     * Seeks to the current position and reads up to $length bytes, or less if
     * it would result in reading past $this->limit
     */
    public function seekAndRead(int $length) : string
    {
        $this->stream->seek($this->offset + $this->position);
        if ($this->limit !== -1) {
            $length = \min($length, $this->limit - $this->position);
            if ($length <= 0) {
                return '';
            }
        }
        return $this->stream->read($length);
    }

    /**
     * Reads from the underlying stream after seeking to the position within the
     * bounds set for this limited stream.  After reading, the wrapped stream is
     * 'seeked' back to its position prior to the call to read().
     *
     * @param int $length
     */
    public function read($length) : string
    {
        $pos = $this->stream->tell();
        $ret = $this->seekAndRead($length);
        $this->position += \strlen($ret);
        $this->stream->seek($pos);
        if ($this->limit !== -1 && $this->position > $this->limit) {
            $ret = \substr($ret, 0, -($this->position - $this->limit));
            $this->position = $this->limit;
        }
        return $ret;
    }
}
