<?php

namespace Velis\Mail;

use DOMDocument;
use Exception;
use Psr\SimpleCache\InvalidArgumentException;
use Velis\App;
use Velis\Arrays;
use Velis\Bpm\Email\Account;
use Velis\Db\Db;
use Velis\Filesystem\FilesystemInterface;
use Velis\Mail\Protocol\SmtpPluginManager;
use Velis\Mail\Protocol\XOauth2Auth;
use Velis\Mail\Transport\Smtp;
use Velis\Mail\Transport\TransportFactory;
use Velis\Model\DataObject;
use Velis\Model\DataObject\NoColumnsException;
use Velis\Model\File;
use Velis\Mvc\Controller\Exception\NotFoundException;
use Velis\Notification;
use Velis\Notification\Attachment;
use Velis\Notification\HeadersTrait;
use Velis\Notification\Log;
use Velis\Output;
use Velis\Queue\QueueInterface;
use Zend\Mail\Address\AddressInterface;
use Zend\Mail\Header\GenericHeader;
use Zend\Mail\Headers;
use Zend\Mail\Message;
use Zend\Mail\Transport\SmtpOptions;
use Zend\Mime\Message as MimeMessage;
use Zend\Mime\Mime;
use Zend\Mime\Part as MimePart;
use Zend\ServiceManager\ServiceManager;

/**
 * Zend mail extension
 * @author Olek Procki <olo@velis.pl>
 */
class Mail extends Message
{
    use HeadersTrait;


    /**
     * Email account for SMTP transport
     * @var Account
     */
    protected $_emailAccount;


    /**
     * Text body part
     * @var string
     */
    protected $_bodyText;


    /**
     * HTML body part
     * @var string
     */
    protected $_bodyHtml;


    /**
     * Execution time
     * @var float
     */
    protected $_execTime = 0;


    /**
     * List of attached files' names
     * @var array
     */
    protected $_attachmentNames = [];


    /**
     * @var array
     */
    protected $_queueFiles = [];


    /**
     * List of attached files
     * @var Attachment[]
     */
    protected $_attachments = [];


    /**
     * Message constructor
     * @param Account|int|null $emailAccount
     * @param Log|null $log
     * @throws NoColumnsException
     * @throws InvalidArgumentException
     */
    public function __construct($emailAccount = null, $log = null)
    {
        $accountFactory = new AccountFactory(
            App::$config->notifications,
            $emailAccount,
            $log
        );
        $this->_emailAccount = $accountFactory->getAccount();

        $this->setFrom(
            $this->_emailAccount->email_address,
            $this->_emailAccount->sender_label
        );
    }


    /**
     * Adds application internal headers
     * @return Mail
     */
    protected function addInternalHeaders(int $notificationLogId = null): self
    {
        if (null === $this->headers) {
            $this->setHeaders(new Headers());
        }

        if (!$this->isErrorRecipient()) {
            $this->headers->addHeader(
                new GenericHeader('Auto-Submitted', 'auto-generated')
            );
        }

        if ($notificationLogId) {
            $this->setAdditionalHeader('X-Singu-Notification-Id', App::$config->settings->instanceAcro . '-' . $notificationLogId);
        }

        if ($this->hasAdditionalHeaders()) {
            foreach ($this->getAdditionalHeaders() as $header => $value) {
                if (!$this->headers->has($header)) {
                    $this->headers->addHeader(
                        new GenericHeader($header, $value)
                    );
                }
            }
        }

        if (App::$config->settings->instanceAcro && !$this->headers->has('X-Instance-Acro')) {
            $this->headers->addHeader(
                new GenericHeader('X-Instance-Acro', App::$config->settings->instanceAcro)
            );
        }

        return $this;
    }


    /**
     * Sends message
     * @return Mail
     */
    public function send(int $notificationLogId = null): self
    {
        $this->addInternalHeaders($notificationLogId);

        $mailFactory = new TransportFactory($this);
        $mailWrapper = $mailFactory->create();

        $startTime = microtime(true);
        $mailWrapper->send($this);
        $this->_execTime = microtime(true) - $startTime;

        return $this;
    }


    /**
     * Retrieve the sender address, if any
     *
     * @return AddressInterface|void
     */
    public function getSender()
    {
        if ($this->getHeaders()->has('sender')) {
            return parent::getSender();
        }
    }


    /**
     * Checks if error account is message recipient
     * @return bool
     */
    public function isErrorRecipient()
    {
        foreach ($this->getTo() as $address) {
            if (trim($address->getEmail()) == trim(App::$config->errorReporting->recipient)) {
                return true;
            }
        }

        return false;
    }


    /**
     * Set mail contents (body & attachments)
     *
     * @param array $params
     * @return Mail
     */
    public function setContents($params): self
    {
        if ($params['body_text']) {
            $this->_bodyText = $params['body_text'];
            $text = new MimePart($params['body_text']);
            $text->type = 'text/plain';
            $text->charset = 'utf-8';
        }

        $inlineImages = [];
        if ($params['body_html']) {
            [$html, $inlineImages] = $this->extractImages($params['body_html']);

            $this->_bodyHtml = $params['body_html'];
            $html = new MimePart($html);
            $html->type = 'text/html';
            $html->charset = 'utf-8';
            $html->encoding = Mime::ENCODING_QUOTEDPRINTABLE;
        }

        $attachments = null;

        if ($params['attachments']) {
            $attachments = self::makeAttachmentParts($params['attachments']);

            $this->_attachmentNames = [];
            foreach ($attachments as $part) {
                $this->_attachmentNames[] = $part->filename;
            }
        }

        $body = new MimeMessage();

        /**
         * necessary MIME structure:
         * - multipart/alternatives:
         *   - text part
         *   - multipart/related
         *     - html part
         *     - inline images as parts
         *   - attachments as parts
         */
        if ($inlineImages) {
            $imageableHtml = new MimeMessage();
            $imageableHtml->setParts(array_merge([$html], $inlineImages));
            $htmlPart = new MimePart($imageableHtml->generateMessage());
            $htmlPart->type = "multipart/related;\n boundary=\"" . $imageableHtml->getMime()->boundary() . "\"";
        } else {
            $htmlPart = $html;
        }

        if ($attachments) {
            $alternatives = new MimeMessage();
            $alternatives->setParts([$text, $htmlPart]);
            $alternativesPart = new MimePart($alternatives->generateMessage());
            $alternativesPart->type = "multipart/alternative;\n boundary=\"" . $alternatives->getMime()->boundary() . "\"";

            $parts = array_merge([$alternativesPart], $attachments);
        } else {
            $parts = array_filter([$text, $htmlPart]);
        }
        $body->setParts($parts);
        $this->setBody($body);

        if (!$attachments) {
            $this->getHeaders()->get('content-type')->setType('multipart/alternative');
        }

        return $this;
    }

    /**
     * Extract the base64 src of <img> tags contained in the HTML body.
     * Then add them as parts (inline attachments) with Content-ID
     * @see https://gist.github.com/basvandorst/3415630
     * @see https://davidnussio.wordpress.com/2008/09/21/inline-images-into-html-email-with-zend-framework/
     *
     * @return array{0: string, 1: array<MimePart>}
     */
    private function extractImages(string $html): array
    {
        $dom = new DOMDocument();
        @$dom->loadHTML($html);

        $images = $dom->getElementsByTagName('img');

        $attachments = [];
        foreach ($images as $image) {
            $src = $image->getAttribute('src');
            if (substr($src, 0, 4) !== 'data') {
                continue;
            }

            preg_match_all('/data:(image\/.+);base64,/', $src, $results);
            $cid = md5($src);

            $mime = new MimePart(file_get_contents($src));
            $mime->id = $cid;
            $mime->type = $results[1][0];
            $mime->disposition = Mime::DISPOSITION_INLINE;
            $mime->encoding = Mime::ENCODING_BASE64;

            $html = str_replace($src, 'cid:' . $cid, $html);

            $attachments[] = $mime;
        }

        return [$html, $attachments];
    }


    /**
     * Creates attachment parts
     *
     * @param mixed $attachments
     * @return MimePart[]
     */
    public static function makeAttachmentParts($attachments)
    {
        $attachmentParts = [];

        if ($attachments) {
            if (!is_array($attachments)) {
                $attachments = array($attachments);
            }

            /** @var FilesystemInterface $filesystem */
            $filesystem = App::$di->get('filesystem');

            foreach ($attachments as $key => $data) {
                $fileName = null;
                $contents = null;

                if (!is_array($data) && !$data instanceof DataObject) {
                    $contents = $data;
                    $fileName = $key;
                } elseif ($data instanceof File) {
                    $contents = $data->getContents();
                    $fileName = $data->getFilename();
                } elseif ($data['link'] && $filesystem->has($data['link'])) {
                    if (!isset($data['contents'])) {
                        $data['contents'] = $filesystem->read($data['link']);
                    }
                    $contents = $data['contents'];
                    $fileName = $data['filename'];
                }

                if ($fileName && $contents) {
                    $mailAttachment = new MimePart($contents);

                    $mailAttachment->filename = $fileName;
                    $mailAttachment->encoding = Mime::ENCODING_BASE64;
                    $mailAttachment->disposition = Mime::DISPOSITION_ATTACHMENT;

                    $attachmentParts[] = $mailAttachment;
                }
            }
        }

        return $attachmentParts;
    }


    /**
     * Creates SMTP transport instance (using default config or EmailAccount data)
     *
     * @param Account|int $account
     * @return Smtp
     * @throws InvalidArgumentException
     */
    public static function getSmtp($account = null)
    {
        $options = new SmtpOptions();
        $config = [];

        if ($account) {
            if (!$account instanceof Account) {
                $account = Account::get($account);
            }
        }

        if ($account instanceof Account) {
            $config['auth'] = $account['outgoing_auth'] ?: 'login';
            $config['username'] = $account->login;
            $config['password'] = $account->password;
            $config['client_id'] = $account->client_id;
            $config['client_secret'] = $account->client_secret ?? $config['password'];
            $config['host'] = $account->outgoing_server;

            if ($account->outgoing_port) {
                $config['port'] = $account->outgoing_port;
            }

            if ($account->outgoing_ssl) {
                $config['ssl'] = $account->outgoing_ssl;
            }
        } else {
            $config['auth'] = App::$config->notifications->smtpAuth ?: 'login';
            $config['username'] = App::$config->notifications->smtpUser;
            $config['password'] = App::$config->notifications->smtpPassword;
            $config['client_id'] = App::$config->notifications->smtpClientId;
            $config['client_secret'] = App::$config->notifications->smtpClientSecret ?? $config['password'];
            $config['host'] = App::$config->notifications->smtpServer;

            if (App::$config->notifications->smtpPort) {
                $config['port'] = App::$config->notifications->smtpPort;
            }
            if (App::$config->notifications->smtpSsl) {
                $config['ssl'] = App::$config->notifications->smtpSsl;
            }
        }

        $options->setHost($config['host']);
        $options->setName($config['host']);

        if ($config['username']) {
            if ($config['port']) {
                $options->setPort($config['port']);
            }
            $options->setConnectionClass($config['auth']);
            $connectionConfig = [
                'username' => $config['username'],
                'password' => $config['password'],
            ];
            if ($config['ssl']) {
                $connectionConfig['ssl'] = $config['ssl'];
            }

            if ($config['auth'] === XOauth2Auth::AUTH_METHOD) {
                $connectionConfig['client_id'] = $config['client_id'];
                $connectionConfig['client_secret'] = $config['client_secret'];
            }
            $options->setConnectionConfig($connectionConfig);
        }
        $smtpTransport = new Smtp($options);

        $serviceManager = new ServiceManager();
        $pluginManager = new SmtpPluginManager($serviceManager);

        $smtpTransport->setPluginManager($pluginManager);

        return $smtpTransport;
    }


    /**
     * Get account for error notification
     * @return Account|bool
     * @throws DataObject\NoColumnsException
     */
    public static function getErrorNotificationAccount()
    {
        if (App::$config->notifications->error) {
            return new Account(Arrays::toArray(App::$config->notifications->error));
        }

        return false;
    }


    /**
     * Creates log entry for saving
     * @param bool $isSent
     * @return Log
     * @throws Exception
     */
    public function log($isSent = true, int $notificationLogId = null)
    {
        $params = [
            'body_txt' => mb_convert_encoding($this->_bodyText, 'UTF-8'),
            'body_html' => mb_convert_encoding($this->_bodyHtml, 'UTF-8'),
            'subject' => mb_convert_encoding($this->getSubject(), 'UTF-8'),
            'attachments' => mb_convert_encoding(implode(', ', $this->_attachmentNames), 'UTF-8'),
        ];

        if ($notificationLogId) {
            $params['notification_log_id'] = $notificationLogId;
        }

        $log = new Log($params);

        $from = [];
        $to = [];
        $cc = [];
        $bcc = [];

        foreach ($this->getFrom() as $address) {
            $from[] = $address->getEmail();
        }

        foreach ($this->getTo() as $address) {
            $to[] = $address->getEmail();
        }

        foreach ($this->getCc() as $address) {
            $cc[] = $address->getEmail();
        }

        foreach ($this->getBcc() as $address) {
            $bcc[] = $address->getEmail();
        }

        $log['sender']          = implode(',', $from);
        $log['recipients']      = implode(',', $to);
        $log['recipients_cc']   = implode(',', $cc);
        $log['recipients_bcc']  = implode(',', $bcc);

        if (!is_int($isSent)) {
            $isSent = (int) $isSent;
        }
        $log['is_sent']         = $isSent;
        $log['send_date']       = date('Y-m-d H:i:s');

        if (!$isSent) {
            $log['unsuccessful_attempts'] = 0;
        } else {
            $log['exec_time'] = $this->_execTime;
        }

        if ($this->_emailAccount) {
            $log['email_account_id'] = $this->_emailAccount['email_account_id'];
        }

        if ($this->hasAdditionalHeaders() && Log::hasField('additional_headers')) {
            $log['additional_headers'] = Output::jsonEncode(
                $this->getAdditionalHeaders()
            );
        }

        return $log->add(true);
    }

    /**
     * @return int
     */
    public function nextLogId(): int
    {
        /** @var Db $db */
        $db = App::getService('db');
        $restore = $db->checkDuplicatedQueries(false);

        $logId = $db->nextval('app.notification_log_tab_notification_log_id_seq');

        $db->checkDuplicatedQueries($restore);

        return $logId;
    }

    /**
     * Adds email attachments to database
     * @param array $attachments
     * @return Attachment[]
     * @throws Exception
     * @throws NotFoundException
     */
    public function addAttachments($attachments = [])
    {
        /** @var FilesystemInterface $filesystem */
        $filesystem = App::$di->get('filesystem');

        foreach ($attachments as $key => $attachment) {
            $params = [];
            $passedByVar = false;
            $path = null;

            if ($attachment instanceof File) {
                $path = $attachment->getStoragePath();
                $params['filename'] =  $attachment->getFilename();
            } elseif (isset($attachment['link']) && $filesystem->has($attachment['link'])) {
                $path = $attachment['link'];
                $params['filename'] = $attachment['filename'];

                // attachment passed as variable
            } elseif (!is_array($attachment) && !is_object($attachment) && is_string($key)) {
                $params['filename'] = $key;
                $passedByVar = true;
            }

            if ($params['filename']) {
                $item = new Attachment($params);
                if ($passedByVar) {
                    $item->setContents($attachment);
                }
                $item->save(true);

                if (!$passedByVar) {
                    if (!$filesystem->has($path)) {
                        throw new NotFoundException(sprintf('Cannot add attachment: file %s does not exist.', $path));
                    }

                    $filesystem->symlink($path, $item->getStorageDir() . $item->getStorageFilename());
                }

                $this->_queueFiles[$item->id()] = $item;
            }
        }

        return $this->_queueFiles;
    }


    /**
     * Dodaj do kolejki maili
     *
     * @param array $attachments
     * @param mixed $queueMode
     *
     * @return Log log item
     *
     * @throws Exception
     */
    public function queue($attachments = null, $queueMode = false)
    {
        if (!$this->_queueFiles) {
            $this->addAttachments($attachments);
        }

        // queue fallback mode when resque notifications disabled
        if ($queueMode == Notification::QUEUE_RESQUE) {
            if (!App::$config->resque->enabled || !Notification::hasResqueQueue()) {
                $queueMode = Notification::QUEUE_CRON;
            }
        }

        $log = $this->log($queueMode);

        foreach ($this->_queueFiles as $file) {
            $file->insertRelation($log->id());
        }

        if ($queueMode == Notification::QUEUE_RESQUE) {
            $enqueueCallback = function () use ($log) {
                $queueName = Notification::WORKER_QUEUE_NAME;
                /** @var QueueInterface $queue */
                $queue = App::$di->get('queue');

                if (App::$config->settings->instanceAcro) {
                    $queueName .= '-' . App::$config->settings->instanceAcro;
                }

                if (App::$config->resque->redis) {
                    $redisServer = App::$config->resque->redis->server . ':';
                    if (App::$config->resque->redis->port) {
                        $redisServer .= App::$config->resque->redis->port;
                    } else {
                        //use default redis port
                        $redisServer .= '6379';
                    }

                    $queue->setBackend($redisServer);
                }

                $queue->enqueue($queueName, Notification\SenderJob::class, [
                    'notification_log_id' => $log->id(),
                ]);
            };

            if (!App::isQueue()) {
                App::onFinish($enqueueCallback);
            } else {
                $enqueueCallback();
            }
        }

        return $log;
    }

    /**
     * Sets mail attachment files
     * @param Attachment[] $attachments
     * @return $this
     */
    public function setAttachments($attachments)
    {
        $this->_attachments = $attachments;

        return $this;
    }

    /**
     * Gets mail attachment files
     * @return Attachment[]
     */
    public function getAttachments()
    {
        return $this->_attachments;
    }

    /**
     * @return Account
     */
    public function getEmailAddress()
    {
        return $this->_emailAccount;
    }
}
