<?php

declare(strict_types=1);

namespace Ddeboer\Imap\Message;

use Ddeboer\Imap\Exception\UnexpectedEncodingException;
use Ddeboer\Imap\ImapResourceInterface;
use Ddeboer\Imap\Message;

/**
 * A message part.
 */
abstract class AbstractPart implements PartInterface
{
    /**
     * @var ImapResourceInterface
     */
    protected $resource;

    /**
     * @var bool
     */
    private $structureParsed = false;

    /**
     * @var array
     */
    private $parts = [];

    /**
     * @var string
     */
    private $partNumber;

    /**
     * @var int
     */
    private $messageNumber;

    /**
     * @var \stdClass
     */
    private $structure;

    /**
     * @var Parameters
     */
    private $parameters;

    /**
     * @var null|string
     */
    private $type;

    /**
     * @var null|string
     */
    private $subtype;

    /**
     * @var null|string
     */
    private $encoding;

    /**
     * @var null|string
     */
    private $disposition;

    /**
     * @var null|string
     */
    private $bytes;

    /**
     * @var null|string
     */
    private $lines;

    /**
     * @var null|string
     */
    private $content;

    /**
     * @var null|string
     */
    private $decodedContent;

    /**
     * @var int
     */
    private $key = 0;

    /**
     * @var array
     */
    private static $typesMap = [
        \TYPETEXT => self::TYPE_TEXT,
        \TYPEMULTIPART => self::TYPE_MULTIPART,
        \TYPEMESSAGE => self::TYPE_MESSAGE,
        \TYPEAPPLICATION => self::TYPE_APPLICATION,
        \TYPEAUDIO => self::TYPE_AUDIO,
        \TYPEIMAGE => self::TYPE_IMAGE,
        \TYPEVIDEO => self::TYPE_VIDEO,
        \TYPEMODEL => self::TYPE_MODEL,
        \TYPEOTHER => self::TYPE_OTHER,
    ];

    /**
     * @var array
     */
    private static $encodingsMap = [
        \ENC7BIT => self::ENCODING_7BIT,
        \ENC8BIT => self::ENCODING_8BIT,
        \ENCBINARY => self::ENCODING_BINARY,
        \ENCBASE64 => self::ENCODING_BASE64,
        \ENCQUOTEDPRINTABLE => self::ENCODING_QUOTED_PRINTABLE,
    ];

    /**
     * @var array
     */
    private static $attachmentKeys = [
        'name' => true,
        'filename' => true,
        'name*' => true,
        'filename*' => true,
    ];

    /**
     * Constructor.
     *
     * @param ImapResourceInterface $resource      IMAP resource
     * @param int                   $messageNumber Message number
     * @param string                $partNumber    Part number
     * @param \stdClass             $structure     Part structure
     */
    public function __construct(
        ImapResourceInterface $resource,
        int $messageNumber,
        string $partNumber,
        \stdClass $structure
    ) {
        $this->resource = $resource;
        $this->messageNumber = $messageNumber;
        $this->partNumber = $partNumber;
        $this->setStructure($structure);
    }

    /**
     * Get message number (from headers).
     *
     * @return int
     */
    final public function getNumber(): int
    {
        $this->assertMessageExists($this->messageNumber);

        return $this->messageNumber;
    }

    /**
     * Ensure message exists.
     *
     * @param int $messageNumber
     */
    protected function assertMessageExists(int $messageNumber): void
    {
    }

    /**
     * @param \stdClass $structure Part structure
     */
    final protected function setStructure(\stdClass $structure): void
    {
        $this->structure = $structure;
    }

    /**
     * Part structure.
     *
     * @return \stdClass
     */
    final public function getStructure(): \stdClass
    {
        $this->lazyLoadStructure();

        return $this->structure;
    }

    /**
     * Lazy load structure.
     */
    protected function lazyLoadStructure(): void
    {
    }

    /**
     * Part parameters.
     *
     * @return Parameters
     */
    final public function getParameters(): Parameters
    {
        $this->lazyParseStructure();

        return $this->parameters;
    }

    /**
     * Part charset.
     *
     * @return null|string
     */
    final public function getCharset(): ?string
    {
        $this->lazyParseStructure();

        return $this->parameters->get('charset') ?: null;
    }

    /**
     * Part type.
     *
     * @return null|string
     */
    final public function getType(): ?string
    {
        $this->lazyParseStructure();

        return $this->type;
    }

    /**
     * Part subtype.
     *
     * @return null|string
     */
    final public function getSubtype(): ?string
    {
        $this->lazyParseStructure();

        return $this->subtype;
    }

    /**
     * Part encoding.
     *
     * @return null|string
     */
    final public function getEncoding(): ?string
    {
        $this->lazyParseStructure();

        return $this->encoding;
    }

    /**
     * Part disposition.
     *
     * @return null|string
     */
    final public function getDisposition(): ?string
    {
        $this->lazyParseStructure();

        return $this->disposition;
    }

    /**
     * Part bytes.
     *
     * @return null|int|string
     */
    final public function getBytes()
    {
        $this->lazyParseStructure();

        return $this->bytes;
    }

    /**
     * Part lines.
     *
     * @return null|string
     */
    final public function getLines(): ?string
    {
        $this->lazyParseStructure();

        return $this->lines;
    }

    /**
     * Get raw part content.
     *
     * @return string
     */
    final public function getContent(): string
    {
        if (null === $this->content) {
            $this->content = $this->doGetContent($this->getContentPartNumber());
        }

        return $this->content;
    }

    /**
     * Get content part number.
     *
     * @return string
     */
    protected function getContentPartNumber(): string
    {
        return $this->partNumber;
    }

    /**
     * Get part number.
     *
     * @return string
     */
    final public function getPartNumber(): string
    {
        return $this->partNumber;
    }

    /**
     * Get decoded part content.
     *
     * @return string
     */
    final public function getDecodedContent(): string
    {
        if (null === $this->decodedContent) {
            if (self::ENCODING_UNKNOWN === $this->getEncoding()) {
                throw new UnexpectedEncodingException('Cannot decode a content with an uknown encoding');
            }

            $content = $this->getContent();
            if (self::ENCODING_BASE64 === $this->getEncoding()) {
                $content = \base64_decode($content);
            } elseif (self::ENCODING_QUOTED_PRINTABLE === $this->getEncoding()) {
                $content = \quoted_printable_decode($content);
            }

            if (false === $content) {
                throw new UnexpectedEncodingException('Cannot decode content');
            }

            // If this part is a text part, convert its charset to UTF-8.
            // We don't want to decode an attachment's charset.
            if (!$this instanceof Attachment && null !== $this->getCharset() && self::TYPE_TEXT === $this->getType()) {
                $content = Transcoder::decode($content, $this->getCharset());
            }

            $this->decodedContent = $content;
        }

        return $this->decodedContent;
    }

    /**
     * Get raw message content.
     *
     * @param string $partNumber
     *
     * @return string
     */
    final protected function doGetContent(string $partNumber): string
    {
        return \imap_fetchbody(
            $this->resource->getStream(),
            $this->getNumber(),
            $partNumber,
            \FT_UID | \FT_PEEK
        );
    }

    /**
     * Get an array of all parts for this message.
     *
     * @return PartInterface[]
     */
    final public function getParts(): array
    {
        $this->lazyParseStructure();

        return $this->parts;
    }

    /**
     * Get current child part.
     *
     * @return mixed
     */
    final public function current()
    {
        $this->lazyParseStructure();

        return $this->parts[$this->key];
    }

    /**
     * Get current child part.
     *
     * @return mixed
     */
    final public function getChildren()
    {
        return $this->current();
    }

    /**
     * Get current child part.
     *
     * @return bool
     */
    final public function hasChildren()
    {
        $this->lazyParseStructure();

        return \count($this->parts) > 0;
    }

    /**
     * Get current part key.
     *
     * @return int
     */
    final public function key()
    {
        return $this->key;
    }

    /**
     * Move to next part.
     *
     * @return int
     */
    final public function next()
    {
        ++$this->key;
    }

    /**
     * Reset part key.
     *
     * @return int
     */
    final public function rewind()
    {
        $this->key = 0;
    }

    /**
     * Check if current part is a valid one.
     *
     * @return bool
     */
    final public function valid()
    {
        $this->lazyParseStructure();

        return isset($this->parts[$this->key]);
    }

    /**
     * Parse part structure.
     */
    private function lazyParseStructure(): void
    {
        if (true === $this->structureParsed) {
            return;
        }
        $this->structureParsed = true;

        $this->lazyLoadStructure();

        $this->type = self::$typesMap[$this->structure->type] ?? self::TYPE_UNKNOWN;

        // In our context, \ENCOTHER is as useful as an uknown encoding
        $this->encoding = self::$encodingsMap[$this->structure->encoding] ?? self::ENCODING_UNKNOWN;
        $this->subtype = $this->structure->subtype;

        foreach (['disposition', 'bytes', 'description'] as $optional) {
            if (isset($this->structure->{$optional})) {
                $this->{$optional} = $this->structure->{$optional};
            }
        }

        $this->parameters = new Parameters();
        if ($this->structure->ifparameters) {
            $this->parameters->add($this->structure->parameters);
        }

        if ($this->structure->ifdparameters) {
            $this->parameters->add($this->structure->dparameters);
        }

        // When the message is not multipart and the body is the attachment content
        // Prevents infinite recursion
        if (self::isAttachment($this->structure) && !$this instanceof Attachment) {
            $this->parts[] = new Attachment($this->resource, $this->getNumber(), '1', $this->structure);
        }

        if (isset($this->structure->parts)) {
            $parts = $this->structure->parts;
            // https://secure.php.net/manual/en/function.imap-fetchbody.php#89002
            if ($this instanceof Attachment && $this->isEmbeddedMessage() && 1 === \count($parts) && \TYPEMULTIPART === $parts[0]->type) {
                $parts = $parts[0]->parts;
            }
            foreach ($parts as $key => $partStructure) {
                $partNumber = (!$this instanceof Message) ? $this->partNumber . '.' : '';
                $partNumber .= (string) ($key + 1);

                $newPartClass = self::isAttachment($partStructure)
                    ? Attachment::class
                    : SimplePart::class
                ;

                $this->parts[] = new $newPartClass($this->resource, $this->getNumber(), $partNumber, $partStructure);
            }
        }
    }

    /**
     * Check if the given part is an attachment.
     *
     * @param \stdClass $part
     *
     * @return bool
     */
    private static function isAttachment(\stdClass $part): bool
    {
        if (isset(self::$typesMap[$part->type]) && self::TYPE_MULTIPART === self::$typesMap[$part->type]) {
            return false;
        }

        // Attachment with correct Content-Disposition header
        if ($part->ifdisposition) {
            if ('attachment' === \strtolower($part->disposition)) {
                return true;
            }

            if (
                    'inline' === \strtolower($part->disposition)
                && self::SUBTYPE_PLAIN !== \strtoupper($part->subtype)
                && self::SUBTYPE_HTML !== \strtoupper($part->subtype)
            ) {
                return true;
            }
        }

        // Attachment without Content-Disposition header
        if ($part->ifparameters) {
            foreach ($part->parameters as $parameter) {
                if (isset(self::$attachmentKeys[\strtolower($parameter->attribute)])) {
                    return true;
                }
            }
        }

        /*
        if ($part->ifdparameters) {
            foreach ($part->dparameters as $parameter) {
                if (isset(self::$attachmentKeys[\strtolower($parameter->attribute)])) {
                    return true;
                }
            }
        }
         */

        return false;
    }
}
