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

namespace ZBateson\MailMimeParser\Message;

use GuzzleHttp\Psr7\CachingStream;
use Psr\Http\Message\StreamInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\ErrorBag;
use ZBateson\MailMimeParser\Stream\StreamFactory;
use ZBateson\MbWrapper\MbWrapper;
use ZBateson\MbWrapper\UnsupportedCharsetException;

/**
 * Holds the stream and content stream objects for a part.
 *
 * Note that streams are not explicitly closed or detached on destruction of the
 * PartSreamContainer by design: the passed StreamInterfaces will be closed on
 * their destruction when no references to them remain, which is useful when the
 * streams are passed around.
 *
 * In addition, all the streams passed to PartStreamContainer should be wrapping
 * a ZBateson\StreamDecorators\NonClosingStream unless attached to a part by a
 * user, this is because MMP uses a single seekable stream for content and wraps
 * it in ZBateson\StreamDecorators\SeekingLimitStream objects for each part.
 *
 * @author Zaahid Bateson
 */
class PartStreamContainer extends ErrorBag
{
    /**
     * @var StreamInterface stream containing the part's headers, content and
     *      children
     */
    protected StreamInterface $stream;

    /**
     * @var StreamInterface a stream containing this part's content
     */
    protected ?StreamInterface $contentStream = null;

    /**
     * @var StreamInterface the content stream after attaching transfer encoding
     *      streams to $contentStream.
     */
    protected ?StreamInterface $decodedStream = null;

    /**
     * @var StreamInterface attached charset stream to $decodedStream
     */
    protected ?StreamInterface $charsetStream = null;

    /**
     * @var bool true if the stream should be detached when this container is
     *      destroyed.
     */
    protected bool $detachParsedStream = false;

    /**
     * @var array<string, ?string> map of the active encoding filter on the current handle.
     */
    private array $encoding = [
        'type' => null,
        'filter' => null
    ];

    /**
     * @var array<string, ?string> map of the active charset filter on the current handle.
     */
    private array $charset = [
        'from' => null,
        'to' => null,
        'filter' => null
    ];

    public function __construct(
        LoggerInterface $logger,
        protected readonly StreamFactory $streamFactory,
        protected readonly MbWrapper $mbWrapper,
        protected readonly bool $throwExceptionReadingPartContentFromUnsupportedCharsets
    ) {
        parent::__construct($logger);
    }

    /**
     * Sets the part's stream containing the part's headers, content, and
     * children.
     */
    public function setStream(StreamInterface $stream) : static
    {
        $this->stream = $stream;
        return $this;
    }

    /**
     * Returns the part's stream containing the part's headers, content, and
     * children.
     */
    public function getStream() : StreamInterface
    {
        // error out if called before setStream, getStream should never return
        // null.
        $this->stream->rewind();
        return $this->stream;
    }

    /**
     * Returns true if there's a content stream associated with the part.
     */
    public function hasContent() : bool
    {
        return ($this->contentStream !== null);
    }

    /**
     * Attaches the passed stream as the content portion of this
     * StreamContainer.
     *
     * The content stream would represent the content portion of $this->stream.
     *
     * If the content is overridden, $this->stream should point to a dynamic
     * {@see ZBateson\Stream\MessagePartStream} that dynamically creates the
     * RFC822 formatted message based on the IMessagePart this
     * PartStreamContainer belongs to.
     *
     * setContentStream can be called with 'null' to indicate the IMessagePart
     * does not contain any content.
     */
    public function setContentStream(?StreamInterface $contentStream = null) : static
    {
        $this->contentStream = $contentStream;
        $this->decodedStream = null;
        $this->charsetStream = null;
        return $this;
    }

    /**
     * Returns true if the attached stream filter used for decoding the content
     * on the current handle is different from the one passed as an argument.
     */
    private function isTransferEncodingFilterChanged(?string $transferEncoding) : bool
    {
        return ($transferEncoding !== $this->encoding['type']);
    }

    /**
     * Returns true if the attached stream filter used for charset conversion on
     * the current handle is different from the one needed based on the passed
     * arguments.
     *
     */
    private function isCharsetFilterChanged(string $fromCharset, string $toCharset) : bool
    {
        return ($fromCharset !== $this->charset['from']
            || $toCharset !== $this->charset['to']);
    }

    /**
     * Attaches a decoding filter to the attached content handle, for the passed
     * $transferEncoding.
     */
    protected function attachTransferEncodingFilter(?string $transferEncoding) : static
    {
        if ($this->decodedStream !== null) {
            $this->encoding['type'] = $transferEncoding;
            $this->decodedStream = new CachingStream($this->streamFactory->getTransferEncodingDecoratedStream(
                $this->decodedStream,
                $transferEncoding
            ));
        }
        return $this;
    }

    /**
     * Attaches a charset conversion filter to the attached content handle, for
     * the passed arguments.
     *
     * @param string $fromCharset the character set the content is encoded in
     * @param string $toCharset the target encoding to return
     */
    protected function attachCharsetFilter(string $fromCharset, string $toCharset) : static
    {
        if ($this->charsetStream !== null) {
            if (!$this->throwExceptionReadingPartContentFromUnsupportedCharsets) {
                try {
                    $this->mbWrapper->convert('t', $fromCharset, $toCharset);
                    $this->charsetStream = new CachingStream($this->streamFactory->newCharsetStream(
                        $this->charsetStream,
                        $fromCharset,
                        $toCharset
                    ));
                } catch (UnsupportedCharsetException $ex) {
                    $this->addError('Unsupported character set found', LogLevel::ERROR, $ex);
                    $this->charsetStream = new CachingStream($this->charsetStream);
                }
            } else {
                $this->charsetStream = new CachingStream($this->streamFactory->newCharsetStream(
                    $this->charsetStream,
                    $fromCharset,
                    $toCharset
                ));
            }
            $this->charsetStream->rewind();
            $this->charset['from'] = $fromCharset;
            $this->charset['to'] = $toCharset;
        }
        return $this;
    }

    /**
     * Resets just the charset stream, and rewinds the decodedStream.
     */
    private function resetCharsetStream() : static
    {
        $this->charset = [
            'from' => null,
            'to' => null,
            'filter' => null
        ];
        $this->decodedStream->rewind();
        $this->charsetStream = $this->decodedStream;
        return $this;
    }

    /**
     * Resets cached encoding and charset streams, and rewinds the stream.
     */
    public function reset() : static
    {
        $this->encoding = [
            'type' => null,
            'filter' => null
        ];
        $this->charset = [
            'from' => null,
            'to' => null,
            'filter' => null
        ];
        $this->contentStream->rewind();
        $this->decodedStream = $this->contentStream;
        $this->charsetStream = $this->contentStream;
        return $this;
    }

    /**
     * Checks what transfer-encoding decoder stream and charset conversion
     * stream are currently attached on the underlying contentStream, and resets
     * them if the requested arguments differ from the currently assigned ones.
     *
     * @param IMessagePart $part the part the stream belongs to
     * @param string $transferEncoding the transfer encoding
     * @param string $fromCharset the character set the content is encoded in
     * @param string $toCharset the target encoding to return
     */
    public function getContentStream(
        IMessagePart $part,
        ?string $transferEncoding,
        ?string $fromCharset,
        ?string $toCharset
    ) : ?StreamInterface {
        if ($this->contentStream === null) {
            return null;
        }
        if (empty($fromCharset) || empty($toCharset)) {
            return $this->getBinaryContentStream($part, $transferEncoding);
        }
        if ($this->charsetStream === null
            || $this->isTransferEncodingFilterChanged($transferEncoding)
            || $this->isCharsetFilterChanged($fromCharset, $toCharset)) {
            if ($this->charsetStream === null
                || $this->isTransferEncodingFilterChanged($transferEncoding)) {
                $this->reset();
                $this->attachTransferEncodingFilter($transferEncoding);
            }
            $this->resetCharsetStream();
            $this->attachCharsetFilter($fromCharset, $toCharset);
        }
        $this->charsetStream->rewind();
        return $this->streamFactory->newDecoratedMessagePartStream(
            $part,
            $this->charsetStream
        );
    }

    /**
     * Checks what transfer-encoding decoder stream is attached on the
     * underlying stream, and resets it if the requested arguments differ.
     */
    public function getBinaryContentStream(IMessagePart $part, ?string $transferEncoding = null) : ?StreamInterface
    {
        if ($this->contentStream === null) {
            return null;
        }
        if ($this->decodedStream === null
            || $this->isTransferEncodingFilterChanged($transferEncoding)) {
            $this->reset();
            $this->attachTransferEncodingFilter($transferEncoding);
        }
        $this->decodedStream->rewind();
        return $this->streamFactory->newDecoratedMessagePartStream($part, $this->decodedStream);
    }

    protected function getErrorBagChildren() : array
    {
        return [];
    }
}
