import logging
import posixpath
import os
from rfc822 import parsedate_tz, mktime_tz

from plesk_mail_migrator.core.entities.utils.imap_flags_utils import IMAPFlagsUtils
from plesk_mail_migrator.core.entities.utils.mail_message_folder_utils import MailMessageFolderUtils
from plesk_mail_migrator.core.provider.provider_enum_parameter import ProviderEnumParameter
from plesk_mail_migrator.core.provider.provider_parameter import ProviderParameter
from plesk_mail_migrator.core.provider.restore_provider import RestoreProvider
from plesk_mail_migrator.utils.string_utils import is_empty


logger = logging.getLogger(__name__)


class MaildirProvider(RestoreProvider):
    DIR_CUR = 'cur'
    DIR_NEW = 'new'
    DIR_TMP = 'tmp'

    MAILNAMES_DIR = 'mailnames-dir'
    MAILDIR_USER = 'maildir-user'
    MAILDIR_GROUP = 'maildir-group'
    SERVER_TYPE = 'server-type'

    def __init__(self):
        self._parameters = [
            ProviderParameter(self.MAILNAMES_DIR, "Base directory with mail messages", '/var/qmail/mailnames/', "dir"),
            ProviderParameter(self.MAILDIR_USER, "User which should own mail messages", "popuser", "user"),
            ProviderParameter(self.MAILDIR_GROUP, "Group which should own mail messages", "popuser", "group"),
            ProviderEnumParameter(
                self.SERVER_TYPE, "Server type: Courier or Dovecot",
                MailServerType.get_all_values(),
                MailServerType.OTHER, "|".join(MailServerType.get_all_values())
            )
        ]

    def get_parameters(self):
        return self._parameters

    def get_title(self):
        return 'Maildir'

    def get_provider_id(self):
        return 'maildir'

    def list_message_ids(self, account):
        """List IDs of messages that were already migrated (already exist on the target server).
        That list is used on the source server to avoid backing up and transferring messages that
        were already transferred.

        See "How the tool works with message IDs" in README for more details.

        :param plesk_mail_migrator.core.entities.mail_account.MailAccount account:
            Account for which we should list existing message IDs
        :rtype: set[str]
        """
        message_ids = set()

        def add_message_ids(cur_dir):
            if os.path.exists(cur_dir):
                for filename in os.listdir(cur_dir):
                    if ':' in filename:
                        message_id = filename[:filename.find(':')]
                    else:
                        message_id = filename
                    logger.debug("Found message with ID '%s'", message_id)
                    message_ids.add(message_id)

        logger.debug("List message IDs in main folder")
        maildir_path = self._get_maildir_path(account)
        main_cur_dir = posixpath.join(maildir_path, self.DIR_CUR)
        add_message_ids(main_cur_dir)

        for folder in self._get_folders(account):
            folder_cur_dir = posixpath.join(maildir_path, folder, self.DIR_CUR)
            logger.debug("List message IDs in '%s'", folder_cur_dir)
            add_message_ids(folder_cur_dir)

        return message_ids

    def do_restore_message(self, account, message):
        """Restore specified mail message for specified mail account.

        :param plesk_mail_migrator.core.entities.mail_account.MailAccount account:
            Account to which message should be restored
        :param plesk_mail_migrator.core.entities.mail_message.MailMessage message:
            Mail message that should be restored
        :rtype: None
        """
        import pwd
        import grp

        uid = pwd.getpwnam(self.get_parameter_value(self.MAILDIR_USER)).pw_uid
        gid = grp.getgrnam(self.get_parameter_value(self.MAILDIR_GROUP)).gr_gid

        maildir_path = self._get_maildir_path(account)

        message_folder = message.folder
        try:
            message_folder = utf7imap_decode(message.folder)
            message_folder = message_folder.encode('utf-8')
            if message_folder != message.folder:
                logger.debug("Successfully decoded folder name '%s' to '%s'", message.folder, message_folder)
        except TypeError as e:
            logger.debug("Failed to decode folder name '%s': %s", message.folder, e)
        message_folder = self._replace_restricted_symbols(message_folder)
        message_folder = MailMessageFolderUtils.unpack(message_folder, '.')

        if is_empty(message_folder) or message_folder.lower() == 'inbox':
            message_dir_path = posixpath.join(maildir_path, 'cur')
        else:
            if message_folder.lower().startswith('inbox.'):
                message_folder = message_folder[len('inbox.'):]

            subdir_path = posixpath.join(maildir_path, '.' + message_folder)
            if not os.path.isdir(subdir_path):
                os.mkdir(subdir_path, 0700)
                os.chown(subdir_path, uid, gid)

                for subdir_item in [self.DIR_CUR, self.DIR_NEW, self.DIR_TMP]:
                    os.mkdir(posixpath.join(subdir_path, subdir_item), 0700)
                    os.chown(posixpath.join(subdir_path, subdir_item), uid, gid)

                with open(posixpath.join(subdir_path, 'maildirfolder'), 'w'):
                    pass

                os.chown(posixpath.join(subdir_path, 'maildirfolder'), uid, gid)
                os.chmod(posixpath.join(subdir_path, 'maildirfolder'), 0o600)
                self._add_folder_to_subscriptions(account, message_folder)

            message_dir_path = posixpath.join(subdir_path, 'cur')

        flags_str = IMAPFlagsUtils.flags_to_maildir_string(message.flags)

        message_path = posixpath.join(message_dir_path, "%s%s" % (message.message_id, flags_str))
        logger.debug("Restore mail message to '%s'", message_path)

        with open(message_path, 'wb'):
            pass

        os.chown(message_path, uid, gid)
        os.chmod(message_path, 0o600)

        with open(message_path, 'wb') as fp:
            fp.write(self._fix_line_endings(message.body))

        epoch = self._get_date_from_mail_body(message.body)
        if epoch:
            logger.debug("Update mtime and atime to '%f'", epoch)
            os.utime(message_path, (epoch, epoch))
        else:
            logger.debug("Could not set the proper atime and mtime: no headers were found in the message")

    @staticmethod
    def _fix_line_endings(message_body):
        """Linux stores mail with \n line endings, but we usually receive \r\n line endings when doing backup via IMAP

        :param str message_body:
        :return: str:
        """
        return message_body.replace('\r\n', '\n')

    @staticmethod
    def _get_date_from_mail_body(message_body, headers=None):
        """Searches date in mail body using passed headers, returns date in epoch format

        :param str message_body:
        :param [str]|None headers:
        :return: float|None:
        """
        date_headers = ['Date:', 'Delivery-date:']
        if headers:
            date_headers = headers

        for date_header in date_headers:
            try:
                date = MaildirProvider._get_value_by_header(message_body, date_header)
                if date:
                    logger.debug("Header '%s' with date '%s' found", date_header, date)
                    return mktime_tz(parsedate_tz(date))
            except Exception as e:
                logger.debug("Failed to get date from header '%s': %s", date_header, e)

        return None

    @staticmethod
    def _get_value_by_header(message_body, header='Date:'):
        """Returns value by header from mail message body

        :param str message_body:
        :param str header:
        :return str|None:
        """
        for line in message_body.replace('\r', '\n').split('\n'):
            if line.startswith(header):
                return line[len(header):].strip()
        return None

    def _add_folder_to_subscriptions(self, account, message_folder):
        """Add folder to subscriptions file, so it is fetched by IMAP clients by default

        :param plesk_mail_migrator.core.entities.mail_account.MailAccount account:
            Account to which message should be restored
        :param string message_folder:
            Name of IMAP folder to add to subscriptions file
        :return:
        """
        if message_folder == '':
            return

        maildir_path = self._get_maildir_path(account)

        server_type = self.get_parameter_value(self.SERVER_TYPE)
        if server_type == MailServerType.DOVECOT:
            subscriptions_file_name = 'subscriptions'
        elif server_type == MailServerType.COURIER:
            subscriptions_file_name = 'courierimapsubscribed'
        else:
            return

        subscriptions_file_path = posixpath.join(maildir_path, subscriptions_file_name)

        if server_type == MailServerType.COURIER:
            folder_line = 'INBOX.%s' % message_folder
        else:
            folder_line = message_folder

        existing_folder_lines = []
        try:
            with open(subscriptions_file_path, 'r') as fp:
                existing_folder_lines = [line.strip() for line in fp.read().split("\n")]
        except IOError:
            # ignore any I/O error reading the file, e.g. when file does not exists
            pass

        if folder_line in existing_folder_lines:
            return

        import pwd
        import grp

        uid = pwd.getpwnam(self.get_parameter_value(self.MAILDIR_USER)).pw_uid
        gid = grp.getgrnam(self.get_parameter_value(self.MAILDIR_GROUP)).gr_gid

        with open(subscriptions_file_path, 'a+') as fp:
            fp.write(folder_line + '\n')

        os.chown(subscriptions_file_path, uid, gid)

    def _get_maildir_path(self, account):
        """Path to "Maildir" directory of specified mail account.

        :param plesk_mail_migrator.core.entities.mail_account.MailAccount account:
            Account to which message should be restored
        :rtype: str
        """
        return posixpath.join(
            self.get_parameter_value(self.MAILNAMES_DIR),
            account.domain.encode('idna').lower(),
            account.user_name.lower(),
            'Maildir'
        )

    def _get_subscriptions_path(self, account):
        """Path to "subscriptions" file which lists IMAP folders

        :param plesk_mail_migrator.core.entities.mail_account.MailAccount account:
            Account to which message should be restored
        :rtype: str
        """
        return posixpath.join(self._get_maildir_path(account), 'subscriptions')

    def _get_folders(self, account):
        """Get list of folders with messages for specified account

        :param plesk_mail_migrator.core.entities.mail_account.MailAccount account:
            Account to which message should be restored
        :rtype: list[str]
        """
        maildir_path = self._get_maildir_path(account)
        return [
            item for item in os.listdir(maildir_path)
            if item.startswith('.') and os.path.isdir(posixpath.join(maildir_path, item))
        ]

    @staticmethod
    def _replace_restricted_symbols(folder_name):
        """Replace symbols which are not allowed in mail folder name

        There are no standards for the restricted symbols. We used https://wiki2.dovecot.org/Plugins/Listescape
        as a reference here.

        :type folder_name: str | unicode
        :rtype: str | unicode
        """
        result = folder_name.replace('.', '-').replace('/', '-')
        if result.startswith('~'):
            result = '-' + result[1:]
        return result


class MailServerType(object):
    @classmethod
    def get_all_values(cls):
        return [cls.COURIER, cls.DOVECOT, cls.OTHER]

    COURIER = 'courier'
    DOVECOT = 'dovecot'
    OTHER = 'other'


def utf7imap_decode(s):
    res = []
    b64_buffer = bytearray()
    for c in s:
        if c == '&' and not b64_buffer:
            b64_buffer.append(c)
        elif c == '-' and b64_buffer:
            if len(b64_buffer) == 1:
                res.append('&')
            else:
                res.append(base64_utf7_decode(b64_buffer[1:]))
            b64_buffer = bytearray()
        elif b64_buffer:
            b64_buffer.append(c)
        else:
            res.append(c)
    if b64_buffer:
        res.append(base64_utf7_decode(b64_buffer[1:]))
    return ''.join(res)


def base64_utf7_decode(s):
    s_utf7 = '+' + s.replace(',', '/') + '-'
    return s_utf7.decode('utf-7')
