<?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\BufferStream;
use GuzzleHttp\Psr7\StreamDecoratorTrait;
use Psr\Http\Message\StreamInterface;
use RuntimeException;

/**
 * GuzzleHttp\Psr7 stream decoder extension for base64 streams.
 *
 * Note that it's expected the underlying stream will only contain valid base64
 * characters (normally the stream should be wrapped in a
 * PregReplaceFilterStream to filter out non-base64 characters for reading).
 *
 * ```
 * $f = fopen(...);
 * $stream = new Base64Stream(new PregReplaceFilterStream(
 *      Psr7\Utils::streamFor($f), '/[^a-zA-Z0-9\/\+=]/', ''
 * ));
 * //...
 * ```
 *
 * For writing, a ChunkSplitStream could come in handy so the output is split
 * into lines:
 *
 * ```
 * $f = fopen(...);
 * $stream = new Base64Stream(new ChunkSplitStream(new PregReplaceFilterStream(
 *      Psr7\Utils::streamFor($f), '/[^a-zA-Z0-9\/\+=]/', ''
 * )));
 * //...
 * ```
 *
 * @author Zaahid Bateson
 */
class Base64Stream implements StreamInterface
{
    use StreamDecoratorTrait;

    /**
     * @var BufferStream buffered bytes
     */
    private readonly BufferStream $buffer;

    /**
     * @var string remainder of write operation if the bytes didn't align to 3
     *      bytes
     */
    private string $remainder = '';

    /**
     * @var int current number of read/written bytes (for tell())
     */
    private int $position = 0;

    public function __construct(private readonly StreamInterface $stream)
    {
        $this->buffer = new BufferStream();
    }

    /**
     * Returns the current position of the file read/write pointer
     */
    public function tell() : int
    {
        return $this->position;
    }

    /**
     * Returns null, getSize isn't supported
     *
     * @return null
     */
    public function getSize() : ?int
    {
        return null;
    }

    /**
     * Not implemented (yet).
     *
     * Seek position can be calculated.
     *
     * @param int $offset
     * @param int $whence
     * @throws RuntimeException
     */
    public function seek($offset, $whence = SEEK_SET) : never
    {
        throw new RuntimeException('Cannot seek a Base64Stream');
    }

    /**
     * Overridden to return false
     */
    public function isSeekable() : bool
    {
        return false;
    }

    /**
     * Returns true if the end of stream has been reached.
     */
    public function eof() : bool
    {
        return ($this->buffer->eof() && $this->stream->eof());
    }

    /**
     * Fills the internal byte buffer after reading and decoding data from the
     * underlying stream.
     *
     * Note that it's expected the underlying stream will only contain valid
     * base64 characters (normally the stream should be wrapped in a
     * PregReplaceFilterStream to filter out non-base64 characters).
     */
    private function fillBuffer(int $length) : void
    {
        $fill = 8192;
        while ($this->buffer->getSize() < $length) {
            $read = $this->stream->read($fill);
            if ($read === '') {
                break;
            }
            $this->buffer->write(\base64_decode($read));
        }
    }

    /**
     * Attempts to read $length bytes after decoding them, and returns them.
     *
     * Note that reading and writing to the same stream may result in wrongly
     * encoded data and is not supported.
     *
     * @param int $length
     */
    public function read($length) : string
    {
        // let Guzzle decide what to do.
        if ($length <= 0 || $this->eof()) {
            return $this->stream->read($length);
        }
        $this->fillBuffer($length);
        $ret = $this->buffer->read($length);
        $this->position += \strlen($ret);
        return $ret;
    }

    /**
     * Writes the passed string to the underlying stream after encoding it to
     * base64.
     *
     * Base64Stream::close or detach must be called.  Failing to do so may
     * result in 1-2 bytes missing from the end of the stream if there's a
     * remainder.  Note that the default Stream destructor calls close as well.
     *
     * Note that reading and writing to the same stream may result in wrongly
     * encoded data and is not supported.
     *
     * @param string $string
     * @return int the number of bytes written
     */
    public function write($string) : int
    {
        $bytes = $this->remainder . $string;
        $len = \strlen($bytes);
        if (($len % 3) !== 0) {
            $this->remainder = \substr($bytes, -($len % 3));
            $bytes = \substr($bytes, 0, $len - ($len % 3));
        } else {
            $this->remainder = '';
        }
        $this->stream->write(\base64_encode($bytes));
        $written = \strlen($string);
        $this->position += $len;
        return $written;
    }

    /**
     * Writes out any remaining bytes at the end of the stream and closes.
     */
    private function beforeClose() : void
    {
        if ($this->isWritable() && $this->remainder !== '') {
            $this->stream->write(\base64_encode($this->remainder));
            $this->remainder = '';
        }
    }

    /**
     * @inheritDoc
     */
    public function close() : void
    {
        $this->beforeClose();
        $this->stream->close();
    }

    /**
     * @inheritDoc
     */
    public function detach()
    {
        $this->beforeClose();
        $this->stream->detach();

        return null;
    }
}
