<?php

declare(strict_types=1);

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

namespace TYPO3\CMS\Core\Mail;

use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Exception\TransportException;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mailer\Transport\TransportInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Header\HeaderInterface;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Part\AbstractPart;
use Symfony\Component\Mime\Part\File;
use Symfony\Component\Mime\RawMessage;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Serializer\PolymorphicDeserializer;
use TYPO3\CMS\Core\Utility\GeneralUtility;

/**
 * Inspired by SwiftMailer, adapted for TYPO3 and Symfony/Mailer
 *
 * @internal This class is experimental and subject to change!
 */
class FileSpool extends AbstractTransport implements DelayedTransportInterface
{
    /**
     * File WriteRetry Limit.
     */
    protected int $retryLimit = 10;

    /**
     * The maximum number of messages to send per flush
     */
    protected int $messageLimit;

    /**
     * The time limit per flush
     */
    protected int $timeLimit;

    /**
     * Create a new FileSpool, storing messages in $path.
     */
    public function __construct(
        protected string $path,
        ?EventDispatcherInterface $dispatcher = null,
        protected readonly ?LoggerInterface $logger = null,
        protected readonly PolymorphicDeserializer $deserializer = new PolymorphicDeserializer(),
    ) {
        parent::__construct($dispatcher, $logger);

        if (!file_exists($this->path)) {
            GeneralUtility::mkdir_deep($this->path);
        }
    }

    /**
     * Stores a message in the queue.
     */
    protected function doSend(SentMessage $message): void
    {
        $fileName = $this->path . '/' . $this->getRandomString(9);
        $i = 0;

        // We try an exclusive creation of the file. This is an atomic
        // operation, it avoids a locking mechanism
        do {
            $fileName .= $this->getRandomString(1);
            $filePointer = @fopen($fileName . '.message', 'x');
        } while ($filePointer === false && ++$i < $this->retryLimit);

        if ($filePointer === false) {
            throw new TransportException('Could not create file for spooling', 1602615347);
        }

        try {
            $ser = serialize($message);
            if (fwrite($filePointer, $ser) === false) {
                throw new TransportException('Could not write file for spooling', 1602615348);
            }
        } finally {
            fclose($filePointer);
        }
    }

    /**
     * Allow to manage the enqueuing retry limit.
     *
     * Default is ten and allows over 64^20 different fileNames
     */
    public function setRetryLimit(int $limit): void
    {
        $this->retryLimit = $limit;
    }

    /**
     * Execute a recovery if for any reason a process is sending for too long.
     *
     * @param int $timeout in second Defaults is for very slow smtp responses
     */
    public function recover(int $timeout = 900): void
    {
        foreach (new \DirectoryIterator($this->path) as $file) {
            $file = (string)$file->getRealPath();

            if (str_ends_with($file, '.message.sending')) {
                $lockedtime = filectime($file);
                if ((time() - $lockedtime) > $timeout) {
                    rename($file, substr($file, 0, -8));
                }
            }
        }
    }

    public function flushQueue(TransportInterface $transport): int
    {
        $directoryIterator = new \DirectoryIterator($this->path);

        $count = 0;
        $time = time();
        foreach ($directoryIterator as $file) {
            $file = (string)$file->getRealPath();

            if (!str_ends_with($file, '.message')) {
                continue;
            }

            /* We try a rename, it's an atomic operation, and avoid locking the file */
            if (rename($file, $file . '.sending')) {
                try {
                    $message = $this->deserializer->deserialize(
                        (string)file_get_contents($file . '.sending'),
                        [
                            SentMessage::class,
                            RawMessage::class,
                            Envelope::class,
                            Address::class,
                            AbstractPart::class,
                            File::class, // This one does not extend AbstractPart
                            Headers::class,
                            HeaderInterface::class,
                        ]
                    );
                } catch (\Throwable $e) {
                    $this->logger?->error(
                        'Serialized message from {fileName} was rejected, because it contains a disallowed class object.',
                        ['fileName' => $file, 'exception' => $e],
                    );
                    rename($file . '.sending', $file . '.invalid');
                    continue;
                }
                if ($message instanceof SentMessage) {
                    // This may throw an exception if something goes wrong.
                    // That is expected and will cause the `.sending` file to remain within the spooler.
                    $transport->send($message->getMessage(), $message->getEnvelope());
                    $count++;
                    unlink($file . '.sending');
                } else {
                    $this->logger?->error(
                        'Serialized message from {fileName} was rejected, because {className} is not an instance of SentMessage.',
                        [
                            'fileName' => $file,
                            'className' => get_debug_type($message),
                        ],
                    );
                    rename($file . '.sending', $file . '.invalid');
                }
            } else {
                /* This message has just been caught by another process */
                continue;
            }

            if ($this->getMessageLimit() && $count >= $this->getMessageLimit()) {
                break;
            }

            if ($this->getTimeLimit() && ($GLOBALS['EXEC_TIME'] - $time) >= $this->getTimeLimit()) {
                break;
            }
        }
        return $count;
    }

    /**
     * Returns a random string needed to generate a fileName for the queue.
     */
    protected function getRandomString(int $count): string
    {
        // This string MUST stay FS safe, avoid special chars
        $base = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_';
        $ret = '';
        $strlen = strlen($base);
        for ($i = 0; $i < $count; ++$i) {
            $ret .= $base[random_int(0, $strlen - 1)];
        }

        return $ret;
    }

    /**
     * Sets the maximum number of messages to send per flush.
     */
    public function setMessageLimit(int $limit): void
    {
        $this->messageLimit = $limit;
    }

    /**
     * Gets the maximum number of messages to send per flush.
     */
    public function getMessageLimit(): int
    {
        return $this->messageLimit;
    }

    /**
     * Sets the time limit (in seconds) per flush.
     */
    public function setTimeLimit(int $limit): void
    {
        $this->timeLimit = $limit;
    }

    /**
     * Gets the time limit (in seconds) per flush.
     */
    public function getTimeLimit(): int
    {
        return $this->timeLimit;
    }

    public function __toString(): string
    {
        return 'FileSpool:' . $this->path;
    }
}
