<?php

/**
 * dmarc-srg - A php parser, viewer and summary report generator for incoming DMARC reports.
 * Copyright (C) 2020-2024 Aleksey Andreev (liuch)
 *
 * Available at:
 * https://github.com/liuch/dmarc-srg
 *
 * This program is free software: you can redistribute it and/or modify it
 * under the terms of the GNU General Public License as published by the Free
 * Software Foundation, either version 3 of the License.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of  MERCHANTABILITY or
 * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
 * more details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program.  If not, see <http://www.gnu.org/licenses/>.
 */

namespace Liuch\DmarcSrg\Mail;

use Liuch\DmarcSrg\Core;
use Liuch\DmarcSrg\ErrorHandler;
use Liuch\DmarcSrg\Exception\SoftException;
use Liuch\DmarcSrg\Exception\LogicException;
use Liuch\DmarcSrg\Exception\MailboxException;

class MailBox
{
    private $conn;
    private $server;
    private $host;
    private $mbox;
    private $name;
    private $uname;
    private $passw;
    private $delim;
    private $attrs;
    private $expunge;
    private $options;

    public function __construct($params)
    {
        if (!is_array($params)) {
            throw new LogicException('Incorrect mailbox params');
        }

        $this->conn = null;
        $this->uname = $params['username'];
        $this->passw = $params['password'];
        if (isset($params['name']) && is_string($params['name']) && strlen($params['name']) > 0) {
            $this->name = $params['name'];
        } else {
            $name = $this->uname;
            $pos = strpos($name, '@');
            if ($pos !== false && $pos !== 0) {
                $name = substr($name, 0, $pos);
            }
            $this->name = $name;
        }
        $this->mbox = $params['mailbox'];
        $this->host = $params['host'];

        $flags = $params['encryption'] ?? '';
        switch ($flags) {
            case 'ssl':
            default:
                $flags = '/ssl';
                break;
            case 'none':
                $flags = '/notls';
                break;
            case 'starttls':
                $flags = '/tls';
                break;
        }
        if (isset($params['novalidate-cert']) && $params['novalidate-cert'] === true) {
            $flags .= '/novalidate-cert';
        }

        $this->server = sprintf('{%s/imap%s}', $this->host, $flags);
        $this->expunge = false;
        $this->options = [];

        if (isset($params['auth_exclude'])) {
            $auth_exclude = $params['auth_exclude'];
            switch (gettype($auth_exclude)) {
                case 'string':
                    $auth_exclude = [ $auth_exclude ];
                    break;
                case 'array':
                    break;
                default:
                    $auth_exclude = null;
                    break;
            }
            if ($auth_exclude) {
                $this->options['DISABLE_AUTHENTICATOR'] = $auth_exclude;
            }
        }
    }

    public function __destruct()
    {
        if (extension_loaded('imap')) {
            $this->cleanup();
        }
    }

    public function childMailbox(string $mailbox_name)
    {
        $this->ensureConnection();
        try {
            $mb_list = imap_list(
                $this->conn,
                self::utf8ToMutf7($this->server),
                self::utf8ToMutf7($this->mbox) . $this->delim . self::utf8ToMutf7($mailbox_name)
            );
        } catch (\ErrorException $e) {
            $mb_list = false;
        }
        $this->ensureErrorLog('imap_list');
        if (!$mb_list) {
            return null;
        }
        $child = clone $this;
        $child->mbox .= $this->delim . $mailbox_name;
        $child->conn = null;
        $child->expunge = false;
        return $child;
    }

    public function name()
    {
        return $this->name;
    }

    public function host()
    {
        return $this->host;
    }

    public function mailbox()
    {
        return $this->mbox;
    }

    public function check()
    {
        try {
            $this->ensureConnection();
            try {
                $res = imap_status(
                    $this->conn,
                    self::utf8ToMutf7($this->server . $this->mbox),
                    SA_MESSAGES | SA_UNSEEN
                );
            } catch (\ErrorException $e) {
                $res = false;
            }
            $error_message = $this->ensureErrorLog();
            if (!$res) {
                throw new MailboxException($error_message ?? 'Failed to get the mail box status');
            }

            if ($this->attrs & \LATT_NOSELECT) {
                throw new MailboxException('The resource is not a mailbox');
            }

            $this->checkRights();
        } catch (MailboxException $e) {
            return ErrorHandler::exceptionResult($e);
        }
        return [
            'error_code' => 0,
            'message'    => 'Successfully',
            'status'     => [
                'messages' => $res->messages,
                'unseen'   => $res->unseen
            ]
        ];
    }

    public function search($criteria)
    {
        $this->ensureConnection();
        try {
            $res = imap_search($this->conn, $criteria, SE_UID);
        } catch (\ErrorException $e) {
            $res = false;
        }
        $error_message = $this->ensureErrorLog('imap_search');
        if ($res === false) {
            if (!$error_message) {
                return [];
            }
            throw new MailboxException(
                'Failed to search email messages',
                -1,
                new \ErrorException($error_message)
            );
        }
        return $res;
    }

    public function sort($criteria, $search_criteria, $reverse)
    {
        $this->ensureConnection();
        try {
            $res = imap_sort($this->conn, $criteria, $reverse ? 1 : 0, SE_NOPREFETCH | SE_UID, $search_criteria);
        } catch (\ErrorException $e) {
            $res = false;
        }
        $error_message = $this->ensureErrorLog('imap_sort');
        if ($res === false) {
            if (!$error_message) {
                return [];
            }
            throw new MailboxException(
                'Failed to sort email messages',
                -1,
                new \ErrorException($error_message)
            );
        }
        return $res;
    }

    public function message($number)
    {
        return new MailMessage($this->conn, $number);
    }

    public function ensureMailbox($mailbox_name)
    {
        $mbn = self::utf8ToMutf7($mailbox_name);
        $srv = self::utf8ToMutf7($this->server);
        $mbo = self::utf8ToMutf7($this->mbox);
        $this->ensureConnection();
        try {
            $mb_list = imap_list($this->conn, $srv, $mbo . $this->delim . $mbn);
        } catch (\ErrorException $e) {
            $mb_list = false;
        }
        $error_message = $this->ensureErrorLog('imap_list');
        if (empty($mb_list)) {
            if ($error_message) {
                throw new MailboxException(
                    'Failed to get the list of mailboxes',
                    -1,
                    new \ErrorException($error_message)
                );
            }

            $new_mailbox = "{$srv}{$mbo}{$this->delim}{$mbn}";
            try {
                $res = imap_createmailbox($this->conn, $new_mailbox);
            } catch (\ErrorException $e) {
                $res = false;
            }
            $error_message = $this->ensureErrorLog('imap_createmailbox');
            if (!$res) {
                throw new MailboxException(
                    'Failed to create a new mailbox',
                    -1,
                    new \ErrorException($error_message ?? 'Unknown')
                );
            }

            try {
                imap_subscribe($this->conn, $new_mailbox);
            } catch (\ErrorException $e) {
            }
            $this->ensureErrorLog('imap_subscribe');
        }
    }

    public function moveMessage($number, $mailbox_name)
    {
        $this->ensureConnection();
        $target = self::utf8ToMutf7($this->mbox) . $this->delim . self::utf8ToMutf7($mailbox_name);
        try {
            $res = imap_mail_move($this->conn, strval($number), $target, CP_UID);
        } catch (\ErrorException $e) {
            $res = false;
        }
        $error_message = $this->ensureErrorLog('imap_mail_move');
        if (!$res) {
            throw new MailboxException(
                'Failed to move a message',
                -1,
                new \ErrorException($error_message ?? 'Unknown')
            );
        }
        $this->expunge = true;
    }

    public function deleteMessage($number)
    {
        $this->ensureConnection();
        try {
            imap_delete($this->conn, strval($number), FT_UID);
        } catch (\ErrorException $e) {
        }
        $this->ensureErrorLog('imap_delete');
        $this->expunge = true;
    }

    public static function resetErrorStack()
    {
        imap_errors();
        imap_alerts();
    }

    private function ensureConnection()
    {
        if (is_null($this->conn)) {
            $error_message = null;
            $srv = self::utf8ToMutf7($this->server);
            try {
                $this->conn = imap_open(
                    $srv,
                    $this->uname,
                    $this->passw,
                    OP_HALFOPEN,
                    0,
                    $this->options
                );
            } catch (\ErrorException $e) {
                $this->conn = null;
            }
            if ($this->conn) {
                $mbx = self::utf8ToMutf7($this->mbox);
                try {
                    $mb_list = imap_getmailboxes($this->conn, $srv, $mbx);
                } catch (\ErrorException $e) {
                    $mb_list = null;
                }
                if ($mb_list && count($mb_list) === 1) {
                    $this->delim = $mb_list[0]->delimiter ?? '/';
                    $this->attrs = $mb_list[0]->attributes ?? 0;
                    try {
                        if (imap_reopen($this->conn, $srv . $mbx)) {
                            return;
                        }
                    } catch (\ErrorException $e) {
                    }
                } else {
                    $error_message = "Mailbox `{$this->mbox}` not found";
                }
            }
            if (!$error_message) {
                $error_message = imap_last_error();
                if (!$error_message) {
                    $error_message = 'Cannot connect to the mail server';
                }
            }
            Core::instance()->logger()->error("IMAP error: {$error_message}");
            self::resetErrorStack();
            if ($this->conn) {
                try {
                    imap_close($this->conn);
                } catch (\ErrorException $e) {
                }
                $this->ensureErrorLog('imap_close');
            }
            $this->conn = null;
            throw new MailboxException($error_message);
        }
    }

    private function ensureErrorLog(string $prefix = 'IMAP error')
    {
        if ($error_message = imap_last_error()) {
            self::resetErrorStack();
            $error_message = "{$prefix}: {$error_message}";
            Core::instance()->logger()->error($error_message);
            return $error_message;
        }
        return null;
    }

    private function checkRights(): void
    {
        if ($this->attrs & \LATT_NOINFERIORS) {
            throw new SoftException('The mailbox may not have any children mailboxes');
        }

        if (!function_exists('imap_getacl')) {
            return;
        }

        $mbox = self::utf8ToMutf7($this->mbox);
        try {
            $acls = imap_getacl($this->conn, $mbox);
        } catch (\ErrorException $e) {
            // It's not possible to get the ACLs information
            $acls = false;
        }
        $this->ensureErrorLog('imap_getacl');
        if ($acls !== false) {
            $needed_rights_map = [
                'l' => 'LOOKUP',
                'r' => 'READ',
                's' => 'WRITE-SEEN',
                't' => 'WRITE-DELETE',
                'k' => 'CREATE'
            ];
            $result = [];
            $needed_rights = array_keys($needed_rights_map);
            foreach ([ "#{$this->uname}", '#authenticated', '#anyone' ] as $identifier) {
                if (isset($acls[$identifier])) {
                    $rights = $acls[$identifier];
                    foreach ($needed_rights as $r) {
                        if (!str_contains($rights, $r)) {
                            $result[] = $needed_rights_map[$r];
                        }
                    }
                    break;
                }
            }
            if (count($result) > 0) {
                throw new SoftException(
                    'Not enough rights. Additionally, these rights are required: ' . implode(', ', $result)
                );
            }
        }
    }

    /**
     * Deletes messages marked for deletion, if any, and closes the connection
     *
     * @return void
     */
    private function cleanup(): void
    {
        self::resetErrorStack();
        if (!is_null($this->conn)) {
            try {
                if ($this->expunge) {
                    imap_expunge($this->conn);
                }
            } catch (\ErrorException $e) {
            }
            $this->ensureErrorLog('imap_expunge');

            try {
                imap_close($this->conn);
            } catch (\ErrorException $e) {
            }
            $this->ensureErrorLog('imap_close');
        }
    }

    /**
     * It's a replacement for the standard function imap_utf8_to_mutf7
     *
     * @param string $s A UTF-8 encoded string
     *
     * @return string|false
     */
    private static function utf8ToMutf7(string $s)
    {
        return mb_convert_encoding($s, 'UTF7-IMAP', 'UTF-8');
    }
}
