<?php

namespace Velis\Bpm\Email;

use Email\Notification as AppNotification;
use Exception;
use RuntimeException;
use User\User;
use Velis\App;
use Velis\Arrays;
use Velis\Bpm\Ticket\Category;
use Velis\Bpm\Ticket\Classification;
use Velis\Bpm\Ticket\Ticket;
use Velis\Bpm\Workflow\Event;
use Velis\Db\Exception as DbException;
use Velis\Exception as VelisException;
use Velis\Filter;
use Velis\Mail\Parser;
use Velis\Mail\Protocol\ImapOAuth2;
use Velis\Mail\Protocol\XOauth2Auth;
use Velis\Model\Cacheable;
use Velis\Model\DataObject;
use Velis\Model\Sanitizable;
use Velis\Notification;
use Zend\Mail\Storage;
use Zend\Mail\Storage\AbstractStorage;
use Zend\Mail\Storage\Folder;
use Zend\Mail\Storage\Part;

/**
 * Email account model
 *
 * @author Bartosz Izdebski <bartosz.izdebski@velis.pl>
 * @author Olek Procki <olo@velis.pl>
 */
class Account extends DataObject implements Cacheable, Sanitizable
{
    public const STORAGE_IMAP = 'imap';
    public const STORAGE_POP3 = 'pop3';

    public const MESSAGE_COUNT_INBOX = 'inbox';
    public const MESSAGE_COUNT_AUTOSUBMITTED = 'auto_submitted';


    /**
     * Default inbox access priv
     * @var array
     */
    protected static $_accessPriv = array(
        'Ticket',
        'EmailModerate'
    );


    /**
     * Default inbox full access priv
     * @var array
     */
    protected static $_fullAccessPriv = array(
        'Ticket',
        'EmailModerateAllAccounts'
    );


    /**
     * Message defaults field map
     * @var array
     */
    protected static $_messageDefaults = [
        'ticket_classification_id' => 'default_ticket_classification_id',
        'ticket_category_id' => 'default_ticket_category_id',
    ];

    /**
     * Recurring messages cache
     * @var array
     */
    protected static $_recurringMessages = [];

    /**
     * Auto reply when sender email not found but ticket id provided
     * @var bool
     */
    protected $_autoReply = false;


    /**
     * Delivery report notification
     * @var \Velis\Notification
     */
    protected $_deliveryReportNotification = null;


    /**
     * @var AbstractStorage
     */
    protected $_storage;


    /**
     * Returns related sql table
     * @return string
     */
    protected function _getTableName()
    {
        return 'app.email_account_tab';
    }


    /**
     * @return string
     */
    protected function _getListDatasource()
    {
        return 'app.email_account ea';
    }


    /**
     * Returns email address
     * @return string
     */
    public function getName()
    {
        return $this->email_address;
    }


    /**
     * Returns access (or full access) priv
     *
     * @param bool $fullAccess
     * @return array
     */
    public static function getAccessPriv($fullAccess = false)
    {
        if (App::$config->anonymize->enabled) {
            return ['', ''];
        }

        if ($fullAccess) {
            return static::$_fullAccessPriv;
        } else {
            return static::$_accessPriv;
        }
    }


    /**
     * Returns category
     * @return Category
     */
    public function getCategory()
    {
        return Category::get($this->default_ticket_category_id);
    }


    /**
     * Returns classification
     * @return Classification
     */
    public function getClassification()
    {
        return Classification::get($this->default_ticket_classification_id);
    }


    /**
     * Returns assigned moderator user
     * @return \Velis\Bpm\User
     */
    public function getModerator()
    {
        return User::get($this->moderator_user_id);
    }


    /**
     * Returns Account by email address
     *
     * @param string $email
     * @param string $folder
     *
     * @return Account
     */
    public static function byEmail($email, $folder = null)
    {
        /** @var Account $account */
        foreach (static::listCached() as $account) {
            if ($account['email_address'] == $email) {
                if ($folder == null || $folder == $account['incoming_imap_folder']) {
                    return $account;
                }
            }
        }
    }


    /**
     * Returns Account list by moderator
     *
     * @param \Velis\User|int $user
     * @return Account[]
     */
    public static function byModerator($user)
    {
        $userId = $user instanceof \Velis\User ? $user->id() : $user;
        $list = [];

        foreach (static::listCached() as $account) {
            if ($account['moderator_user_id'] == $userId) {
                $list[$account->id()] = $account;
            }
        }

        return $list;
    }


    /**
     * Returns list of active email accounts
     * @return Account[]
     */
    public static function listActive()
    {
        $accounts = [];

        /** @var Account $account */
        foreach (static::listCached() as $account) {
            if ($account->active) {
                $accounts[$account->id()] = $account;
            }
        }

        return $accounts;
    }


    /**
     * Returns true if account uses POP3
     * @return bool
     */
    public function isPop3()
    {
        return $this->incoming_method == self::STORAGE_POP3;
    }


    /**
     * Creates remote connection
     *
     * @return AbstractStorage
     * @throws RuntimeException when mail protocol not supported
     */
    public function connect()
    {
        if (!isset($this->_storage)) {
            $class = 'Zend\Mail\Storage\\' . ucfirst($this->incoming_method);

            if (!class_exists($class)) {
                throw new RuntimeException('Unsupported incoming method');
            }

            $params = [
                'host' => $this->incoming_server,
                'user' => $this->login,
                'password' => $this->password,
            ];

            if ($this->incoming_port) {
                $params['port'] = $this->incoming_port;
            } elseif ($this->incoming_method == self::STORAGE_IMAP) {
                $params['port'] = 993;
            }

            if ($this->incoming_ssl) {
                $params['ssl'] = strtoupper($this->incoming_ssl);
            }

            if ($this->outgoing_auth === XOauth2Auth::AUTH_METHOD) {
                $params = $this->getOAuth2();
            }

            $this->_storage = new $class($params);

            try {
                if ($this->incoming_method == self::STORAGE_IMAP && $this->incoming_imap_folder) {
                    // Typical folder path
                    $this->_storage->selectFolder(mb_convert_encoding('INBOX.' . $this->incoming_imap_folder, "UTF7-IMAP"));
                }
            } catch (Exception $e) {
                try {
                    // MS Exchange path style
                    $this->_storage->selectFolder(mb_convert_encoding('INBOX/' . $this->incoming_imap_folder, "UTF7-IMAP"));
                } catch (Exception $e) {
                    // Any other including GMail
                    $this->_storage->selectFolder(mb_convert_encoding($this->incoming_imap_folder, "UTF7-IMAP"));
                }
            }
        }

        return $this->_storage;
    }


    /**
     * @param Part $item
     * @return bool
     */
    protected function _isAutoSubmitted($item)
    {
        $autoSubmittedHeaders = [
            'auto-submitted',
            'x-autoreply',
            'x-list',
            'x-loop',
            'x-failed-recipients',
            'x-ms-exchange-inbox-rules-loop',
        ];

        $headers = $item->getHeaders();

        if ($headers->has('x-crm-exchange')) {
            return false;
        }

        if ($headers->get('Return-Path')?->getFieldValue() === '<>') {
            return true;
        }

        if ($headers->has('auto-submitted')) {
            if (
                $headers->has('X-Instance-Acro') &&
                $headers->get('X-Instance-Acro')->getFieldValue() != App::$config->settings->instanceAcro
            ) {
                // possible Singu2Singu integration
                // process as regular message only when email is related with ticket
                return !$headers->has('X-Ticket-Id');
            }
        }

        foreach ($autoSubmittedHeaders as $header) {
            if (is_array($header)) {
                if ($item->getHeaders()->has($header[0])) {
                    if ($item->getHeaders()->get($header[0])->getParam($header[1])) {
                        return true;
                    }
                }
            } else {
                if ($item->getHeaders()->has($header)) {
                    return true;
                }
            }
        }

        // also check if it's delivery report message
        if ($this->_isDeliveryReport($item)) {
            return true;
        }

        return false;
    }


    /**
     * Returns account available for the logged-in user
     * @return Account[]
     */
    public static function getUserAvailableAccounts($allPrivate = false)
    {
        if (!App::$user->hasPriv(static::$_accessPriv)) {
            return array();
        }

        $accounts = self::listActive();
        foreach ($accounts as $accountId => $account) {
            if (
                $account['is_private']
                && $account->moderator_user_id != App::$user->id()
                && !$allPrivate
            ) {
                unset($accounts[$accountId]);
                continue;
            }

            if (
                $account->moderator_user_id
                && $account->moderator_user_id != App::$user->id()
                && !App::$user->hasPriv(static::$_fullAccessPriv)
            ) {
                unset($accounts[$accountId]);
                continue;
            }

            $userDepartment = self::executeNestedQuery(fn () => App::$user->getDepartment());
            $departmentId = $userDepartment['main_department_id'] ?? $userDepartment['department_id'];
            if (
                self::hasField('department_id')
                && $account->department_id
                && $departmentId !== $account->department_id
            ) {
                unset($accounts[$accountId]);
                continue;
            }
        }

        return $accounts;
    }


    /**
     * Returns actual inbox count for customer
     * @param string $mode
     * @return int
     */
    public static function getInboxCount($mode = self::MESSAGE_COUNT_INBOX)
    {
        $stats = App::$cache['inbox_count'];

        if (null === $stats) {
            $query = "SELECT email_account_id,";

            if (Message::hasField('auto_submitted')) {
                $query .= "COUNT(email_message_id) - SUM(auto_submitted) AS inbox_count,
                           SUM(auto_submitted) AS auto_submitted_count";
            } else {
                $query .= "COUNT(email_message_id) AS inbox_count";
            }

            $query .= " FROM app.email_account_tab
                             LEFT JOIN app.email_message_tab USING(email_account_id)
                       WHERE is_spam IS DISTINCT FROM 1
                             AND is_pending = 1
                       GROUP BY email_account_id";


            foreach (self::$_db->getAll($query) as $row) {
                $stats[$row['email_account_id']] = [
                    self::MESSAGE_COUNT_INBOX => $row['inbox_count'],
                    self::MESSAGE_COUNT_AUTOSUBMITTED => $row['auto_submitted_count'] ?: 0,
                ];
            }
            App::$cache['inbox_count'] = $stats;
        }

        $inboxCount = 0;
        $availableAccounts = self::getUserAvailableAccounts();

        if (!is_array($stats)) {
            $stats = [];
        }

        if ($stats) {
            foreach ($stats as $emailAccountId => $accountInboxStats) {
                if (in_array($emailAccountId, array_keys($availableAccounts))) {
                    $inboxCount += $accountInboxStats[$mode];
                }
            }
        }

        return $inboxCount;
    }


    /**
     * Resets cached inbox stats
     */
    public static function resetInboxCount()
    {
        unset(App::$cache['inbox_count']);
    }


    /**
     * Imports emails from server
     * @param array $errors
     * @return int
     */
    public function importMessages(&$errors = [])
    {
        try {
            $this->connect();
        } catch (Exception $e) {
            $errors[] = $e->getMessage();
            return 0;
        }
        $emailCount = 0;
        $importedTicketMessages = [];

        if ($this->_storage->countMessages()) {
            for ($i = count($this->_storage); $i; --$i) {
                try {
                    $item = $this->_storage[$i];
                } catch (Exception $e) {
                    continue;
                }

                $itemNo = $i;
                $autoSubmitted = $this->_isAutoSubmitted($item);

                if ($autoSubmitted && !Message::hasField('auto_submitted')) {
                    continue;
                }

                try {
                    if ($this->_isBounce($item)) {
                        throw new BounceMessageException();
                    }

                    if (class_exists('\Email\Message')) {
                        $message = new \Email\Message($item, $this);
                    } else {
                        $message = new Message($item, $this);
                    }

                    foreach (static::$_messageDefaults as $field => $objField) {
                        $message[$field] = $this[$objField];
                    }

                    $message->resolveAttributes();

                    if ($autoSubmitted) {
                        $message['auto_submitted'] = 1;
                    }

                    if (
                        !$autoSubmitted &&
                        $message['ticket_id'] &&
                        ($message['owner_user_id'] || $message['person_id'])
                    ) {
                        if (
                            App::$config->notifications->blockRecurring->checkTickets
                            && $this->matchesRecurring($message)
                        ) {
                            throw new DuplicatedMessageException();
                        }

                        $message->add(true);

                        try {
                            $selectedAttachments = array_keys($message->getAttachments());

                            if ($ticket = $message->getTicket()) {
                                // access protection before message is automatically converted to post
                                if ($message['owner_user_id'] && !$ticket->checkAccess($message['owner_user_id'])) {
                                    Event::fire(Message::EVENT_RECEIVED, $message);
                                } elseif ($message['person_id'] && !$ticket->hasPerson($message['person_id'])) {
                                    Event::fire(Message::EVENT_RECEIVED, $message);
                                } else {
                                    $postAuthor = $message->getOwner();

                                    if ($ticket->isFinished()) {
                                        $canReopen = $postAuthor instanceof User && $postAuthor->hasPriv('Ticket', 'Reopen');
                                        if ($message->hasAdditionalHeader('X-External-Ticket-Id') || $canReopen) {
                                            //In case integration add post without reopening and ticket modification
                                            $message->toPost($ticket, $selectedAttachments);
                                        } elseif (!$canReopen) {
                                            Event::fire(Message::EVENT_RECEIVED, $message);
                                        }
                                    } else {
                                        $message->toPost($ticket, $selectedAttachments);

                                        if (!in_array($ticket->id(), $importedTicketMessages)) {
                                            // new instance to update direction only
                                            $messageTicket = new Ticket($ticket->id());
                                            $messageTicket['direction'] = '>';
                                            $messageTicket->modify();
                                            $importedTicketMessages[] = $ticket->id();
                                        }
                                    }
                                }
                            }
                        } catch (Exception $e) {
                            VelisException::raise($e->getMessage(), $e->getCode(), $e);
                            $errors[] = $e->getMessage();
                            continue;
                        }
                    } elseif (!$autoSubmitted && !$this->_isNoreplyRecipient($message['sender_email'])) {
                        $sender = new Alias($message['sender_email']);

                        if ($this->matchesRecurring($message)) {
                            throw new DuplicatedMessageException();
                        }

                        $message->add(true);

                        if ($this->_autoReply) {
                            if (Filter::validateEmail($sender->getEmail()) && $sender->getEmail() != $this->email_address) {
                                try {
                                    if (class_exists(AppNotification::class)) {
                                        $notification = new AppNotification(AppNotification::AUTO_REPLY, $this);
                                    } else {
                                        $notification = new Notification(Notification::AUTO_REPLY, $this);
                                    }
                                    $notification->message = $message;
                                    $notification->content = $this['auto_reply_content'];
                                    $notification->send($sender);
                                } catch (Exception $e) {
                                    $errors[] = $e->getMessage();
                                }
                            }
                        } elseif (!$parsed = $message->customParse()) {
                            // send delivery report to sender
                            try {
                                // message loop prevention check
                                if ($sender->getEmail() != $this->email_address) {
                                    $this->_sendDeliveryReport(
                                        $message,
                                        $sender,
                                        $autoSubmitted
                                    );
                                }
                            } catch (Exception $e) {
                                $errors[] = $e->getMessage();
                            }
                        }
                        if (!$parsed) {
                            Event::fire(Message::EVENT_RECEIVED, $message);
                        }
                    }

                    if ($this->incoming_method == self::STORAGE_IMAP && $message['sender_email'] == $this['email_address']) {
                        $this->_moveToSent($itemNo);
                    } elseif ($this->incoming_method == self::STORAGE_IMAP && $this->incoming_imap_folder) {
                        $this->_storage->setFlags($itemNo, array(Storage::FLAG_PASSED, Storage::FLAG_SEEN));
                        $this->_storage->moveMessage($itemNo, new Folder('INBOX'));
                    } else {
                        unset($this->_storage[$itemNo]);
                    }

                    // Save the message in case it hasn't been saved already
                    if (!$message->id()) {
                        $message->add();
                    }

                    $emailCount++;
                } catch (Exception $e) {
                    if ($e instanceof DuplicatedMessageException || $e->getCode() == DbException::CODE_DUPLICATED || $e instanceof BounceMessageException || $this->_isDeliveryReport($item)) {
                        unset($this->_storage[$itemNo]);
                    } else {
                        VelisException::raise($e->getMessage(), $e->getCode(), $e);
                        $errors[] = $e->getMessage();
                    }
                    continue;
                }
            }
        }

        return $emailCount;
    }


    /**
     * Returns decoded subject from storage message headers
     *
     * @param int $itemNo
     * @return string
     */
    protected function _getMessageSubject($itemNo)
    {
        $parser = new Parser();
        return $parser->getSubjectFromHeaders(
            $this->_storage->getRawHeader($itemNo)
        );
    }

    /**
     * Returns account folders
     * @return array
     */
    public function getFolders()
    {
        $folders = array();

        foreach ($this->_storage->getFolders() as $folder) {
            $folders[mb_convert_encoding($folder->getGlobalName(), 'UTF-8', "UTF7-IMAP")] = $folder->getLocalName();
        }

        return $folders;
    }


    /**
     * Moves message to sent folder (IMAP)
     * @param int $itemNo
     */
    protected function _moveToSent($itemNo)
    {
        // set SEEN/PASSED flags for message first
        $this->_storage->setFlags(
            $itemNo,
            array(Storage::FLAG_PASSED, Storage::FLAG_SEEN)
        );

        // possible Sent folder names
        $sentFolders = array(
            'Sent',
            'Sent Mail',
            'Sent Messages',
            'Wysłane',
            'Elementy wysłane'
        );

        // folders from account
        $accountFolders = $this->getFolders();

        $movedToSent = false;
        foreach ($sentFolders as $sentFolder) {
            if (array_key_exists($sentFolder, $accountFolders)) {
                try {
                    $this->_storage->moveMessage(
                        $itemNo,
                        new Folder($accountFolders[$sentFolder])
                    );
                } catch (Exception $e) {
                    continue;
                }
                $movedToSent = true;
                break;
            }
        }

        if (!$movedToSent) {
            // Last chance - Sent folder located in INBOX (Thunderbird way)
            try {
                $this->_storage->moveMessage($itemNo, new Folder('INBOX.Sent'));
            } catch (Exception $e) {
                // No 'Sent' folder available - create new one
                try {
                    $this->_storage->createFolder('Sent');
                    $this->_storage->moveMessage($itemNo, new Folder('Sent'));
                } catch (Exception $e) {
                    // if everything fails
                    if ($this->incoming_imap_folder) {
                        // we can move message to INBOX unless we read single account folder
                        $this->_storage->moveMessage($itemNo, new Folder('INBOX'));
                    } else {
                        // delete message otherwise
                        unset($this->_storage[$itemNo]);
                    }
                }
            }
        }
    }


    /**
     * Placeholder for application specific delivery report(s)
     */
    protected function _sendDeliveryReport($message, $sender, $isAutoSubmitted)
    {
    }

    /**
     * Trying to match the imported message with the recently received messages
     * to eliminate autoresponder loops and repetitive messages.
     *
     * @param \Email\Message|Message $message
     *
     * @return bool
     */
    protected function matchesRecurring($message)
    {
        if (!App::$config->notifications->blockRecurring->enabled) {
            return false;
        }

        $matchingFields = [
            'sender_email' => $message['sender_email'],
            'subject' => $message['subject'],
            'body' => $message['body'],
            'email_account_id' => $message['email_account_id']
        ];

        $attachments = $message->getAttachments();
        if (is_array($attachments)) {
            $filenames = Arrays::getColumn($attachments, 'filename');
            asort($filenames);
            $filenames = implode($filenames);
            $query = 'SELECT string_agg(filename, \'\' ORDER BY filename)
                        FROM app.email_message_attachment_tab emat
                        WHERE emt.email_message_id = emat.email_message_id';
            $query = "($query)";
            $matchingFields[$query] = $filenames;
        }

        if (App::$config->notifications->blockRecurring->checkTickets) {
            $matchingFields['ticket_id'] = $message['ticket_id'];
        }

        $hash = hash('md5', implode($matchingFields));

        if (!array_key_exists($hash, self::$_recurringMessages)) {
            self::$_recurringMessages[$hash] = 1;
        } elseif (++self::$_recurringMessages[$hash] >= 2) {
            return true;
        }

        $queryHashFields = implode(',', array_keys($matchingFields));

        $matches = self::$_db->getRow('
            SELECT
                COUNT(email_message_id) AS "daily",
                COUNT(CASE WHEN v.diff < :recurring_frequency * 60 THEN 1 ELSE NULL END) AS "frequently"
            FROM (
                SELECT
                    email_message_id,
                    EXTRACT(epoch FROM date_sent - LAG(date_sent) OVER (ORDER BY date_sent)) AS diff
                FROM (
                    SELECT email_message_id, date_sent
                    FROM app.email_message_tab emt
                    WHERE
                        date_sent > NOW() - INTERVAL \'1 DAY\'
                        AND LOWER(MD5(CONCAT(' . $queryHashFields . '))) = :hash
                ) recurring
            ) v
        ', [
            // count same messages that come more often than every :recurring_frequency minutes
            'recurring_frequency' => App::$config->notifications->blockRecurring->frequency ?: 5,
            'hash' => $hash,
        ]);

        if ($matches['frequently'] > 0) {
            return true;
        }

        if (
            App::$config->notifications->blockRecurring->messagesPerDay
            && $matches['daily'] > App::$config->notifications->blockRecurring->messagesPerDay
        ) {
            return true;
        }

        return false;
    }

    /**
     * Check if email address should be added to the To header rather than Bcc,
     * due to Outlook issues when no email address is added in the To header.
     * noreplyRecipients setting must NOT be used to exclude email address
     * from being notified by a reply about message delivery!
     *
     * @param string $email
     * @return bool
     */
    protected function _isNoreplyRecipient($email)
    {
        if (!App::$config->notifications->noreplyRecipients) {
            return false;
        }

        $noReplyRecipients = Arrays::toArray(App::$config->notifications->noreplyRecipients);

        return in_array($email, $noReplyRecipients);
    }


    /**
     * Message in the qmail-send bounce message format (QSBMF) sended by mailer daemon
     *
     * @param Part $item
     * @return bool
     */
    protected function _isBounce($item)
    {
        $headers = $item->getHeaders();

        if (
            !$headers->has('content-type') &&
            $headers->has('Return-Path') &&
            $headers->get('Return-Path')->getFieldValue() == '<>' &&
            $headers->has('Subject') &&
            (
                $headers->get('Subject')->getFieldValue() == 'failure notice' ||
                $headers->get('Subject')->getFieldValue() == '[Postmaster] Email Delivery Failure'
            )
        ) {
            try {
                $content = $item->getContent();
                if (
                    $content &&
                    strpos($content, "Hi. This is the") === 0 &&
                    $item->getSize() > 2000
                ) {
                    return true;
                }
            } catch (Exception $e) {
            }
        }

        return false;
    }

    /**
     * Check if message is a delivery report
     *
     * @param Part $item
     * @return bool
     */
    protected function _isDeliveryReport($item)
    {
        if ($item->getHeaders()->has('content-type')) {
            if ($item->getHeaders()->get('content-type')->getParameter('report-type') == 'delivery-status') {
                return true;
            }
        }

        return false;
    }

    /**
     * @return ImapOAuth2
     */
    private function getOAuth2(): ImapOAuth2
    {
        $params = new ImapOAuth2(
            $this->incoming_server,
            $this->incoming_port,
            strtoupper($this->incoming_ssl),
            $this->client_id,
            $this->client_secret
        );

        if (!$params->login($this->login, $this->password)) {
            throw new RuntimeException('cannot login, user or password wrong');
        }
        return $params;
    }
}
