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

namespace ZBateson\MailMimeParser\Parser;

use Psr\Log\LogLevel;
use ZBateson\MailMimeParser\Message\Factory\PartHeaderContainerFactory;
use ZBateson\MailMimeParser\Message\PartHeaderContainer;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMessageProxyFactory;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxy;
use ZBateson\MailMimeParser\Parser\Proxy\ParserMimePartProxyFactory;
use ZBateson\MailMimeParser\Parser\Proxy\ParserPartProxy;

/**
 * Parses content and children of MIME parts.
 *
 * @author Zaahid Bateson
 */
class MimeParserService extends AbstractParserService
{
    public function __construct(
        ParserMessageProxyFactory $parserMessageProxyFactory,
        ParserMimePartProxyFactory $parserMimePartProxyFactory,
        PartBuilderFactory $partBuilderFactory,
        protected readonly PartHeaderContainerFactory $partHeaderContainerFactory,
        protected readonly HeaderParserService $headerParser,
        protected readonly int $maxMimePartDepth = 256
    ) {
        parent::__construct($parserMessageProxyFactory, $parserMimePartProxyFactory, $partBuilderFactory);
    }

    /**
     * Returns true if the passed PartBuilder::isMime() method returns true.
     *
     */
    public function canParse(PartBuilder $part) : bool
    {
        return $part->isMime();
    }

    /**
     * Reads up to 2048 bytes of input from the passed resource handle,
     * discarding portions of a line that are longer than that, and returning
     * the read portions of the line.
     *
     * The method also calls $proxy->setLastLineEndingLength which is used in
     * findContentBoundary() to set the exact end byte of a part.
     *
     * @param resource $handle
     */
    private function readBoundaryLine($handle, ParserMimePartProxy $proxy) : string
    {
        $size = 2048;
        $isCut = false;
        $line = \fgets($handle, $size);
        while (\strlen($line) === $size - 1 && \substr($line, -1) !== "\n") {
            $line = \fgets($handle, $size);
            $isCut = true;
        }
        $ret = \rtrim($line, "\r\n");
        $proxy->setLastLineEndingLength(\strlen($line) - \strlen($ret));
        return ($isCut) ? '' : $ret;
    }

    /**
     * Reads 2048-byte lines from the passed $handle, calling
     * $partBuilder->setEndBoundaryFound with the passed line until it returns
     * true or the stream is at EOF.
     *
     * setEndBoundaryFound returns true if the passed line matches a boundary
     * for the $partBuilder itself or any of its parents.
     *
     * Lines longer than 2048 bytes are returned as single lines of 2048 bytes,
     * the longer line is not returned separately but is simply discarded.
     *
     * Once a boundary is found, setStreamPartAndContentEndPos is called with
     * the passed $handle's read pos before the boundary and its line separator
     * were read.
     */
    private function findContentBoundary(ParserMimePartProxy $proxy) : static
    {
        $handle = $proxy->getMessageResourceHandle();
        // last separator before a boundary belongs to the boundary, and is not
        // part of the current part, if a part is immediately followed by a
        // boundary, this could result in a '-1' or '-2' content length
        while (!\feof($handle)) {
            $endPos = \ftell($handle) - $proxy->getLastLineEndingLength();
            $line = $this->readBoundaryLine($handle, $proxy);
            if (\substr($line, 0, 2) === '--' && $proxy->setEndBoundaryFound($line)) {
                $proxy->setStreamPartAndContentEndPos($endPos);
                return $this;
            }
        }
        $proxy->setStreamPartAndContentEndPos(\ftell($handle));
        $proxy->setEof();
        return $this;
    }

    public function parseContent(ParserPartProxy $proxy) : static
    {
        \assert($proxy instanceof ParserMimePartProxy);
        $proxy->setStreamContentStartPos($proxy->getMessageResourceHandlePos());
        $this->findContentBoundary($proxy);
        return $this;
    }

    /**
     * Calls the header parser to fill the passed $headerContainer, then calls
     * $this->parserManager->createParserProxyFor($child);
     *
     * The method first checks though if the 'part' represents hidden content
     * past a MIME end boundary, which some messages like to include, for
     * instance:
     *
     * ```
     * --outer-boundary--
     * --boundary
     * content
     * --boundary--
     * some hidden information
     * --outer-boundary--
     * ```
     *
     * In this case, $this->parserPartProxyFactory is called directly to create
     * a part, $this->parseContent is called immediately to parse it and discard
     * it, and null is returned.
     */
    private function createPart(ParserMimePartProxy $parent, PartHeaderContainer $headerContainer, PartBuilder $child) : ?ParserPartProxy
    {
        if (!$parent->isEndBoundaryFound()) {
            $this->headerParser->parse(
                $child->getMessageResourceHandle(),
                $headerContainer
            );
            $parserProxy = $this->parserManager->createParserProxyFor($child);
            return $parserProxy;
        }
        // reads content past an end boundary if there is any
        $parserProxy = $this->parserPartProxyFactory->newInstance($child, $this);
        $this->parseContent($parserProxy);
        return null;
    }

    public function parseNextChild(ParserMimePartProxy $proxy) : ?ParserPartProxy
    {
        if ($proxy->isParentBoundaryFound()) {
            return null;
        }
        if ($this->exceedsMaxDepth($proxy)) {
            $proxy->addError(
                'Maximum MIME part nesting depth of ' . $this->maxMimePartDepth . ' reached',
                LogLevel::ERROR
            );
            return null;
        }
        $headerContainer = $this->partHeaderContainerFactory->newInstance();
        $child = $this->partBuilderFactory->newChildPartBuilder($headerContainer, $proxy);
        return $this->createPart($proxy, $headerContainer, $child);
    }

    /**
     * Returns true if adding a child to $proxy would exceed $maxMimePartDepth.
     */
    private function exceedsMaxDepth(ParserMimePartProxy $proxy) : bool
    {
        $depth = 1;
        for ($p = $proxy->getParent(); $p !== null; $p = $p->getParent()) {
            if (++$depth >= $this->maxMimePartDepth) {
                return true;
            }
        }
        return false;
    }
}
