import time
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional, Union

from RPA.application import BaseApplication, COMError
from RPA.Email.common import counter_duplicate_path

RECIPIENT_TYPE_TO = 1
RECIPIENT_TYPE_CC = 2
RECIPIENT_TYPE_BCC = 3


class InvalidNamesError(Exception):
    """Raised when email recipient names are not recognized."""


class Application(BaseApplication):
    # pylint: disable=C0301
    """`Outlook.Application` is a library for controlling the Outlook application.

    **About Email Filtering**

    Emails can be filtered according to specification set by Restrict method of the
    Item class https://docs.microsoft.com/en-us/office/vba/api/outlook.items.restrict.

    Couple of examples:

    .. code-block:: robotframework

        Get Emails
        ...   email_filter=[Subject]='test email'

        Move Emails
        ...   email_filter=[SenderEmailAddress]='hello@gmail.com'

    **Examples**

    **Robot Framework**

    .. code-block:: robotframework

        *** Settings ***
        Library                 RPA.Outlook.Application
        Task Setup              Open Application
        Suite Teardown          Quit Application

        *** Variables ***
        ${RECIPIENT}            address@domain.com

        *** Tasks ***
        Send email
            Send Email         recipients=${RECIPIENT}
            ...                subject=This is the subject
            ...                body=This is the message body
            ...                attachments=approved.png

    **Python**

    .. code-block:: python

        from RPA.Outlook.Application import Application

        def send_email():
            app = Application()
            app.open_application()
            app.send_email(
                recipients='EMAILADDRESS_1, EMAILADDRESS_2',
                subject='email subject',
                body='email body message',
                attachments='../orders.csv'

    For more information, see: https://docs.microsoft.com/en-us/previous-versions/office/developer/office-2007/bb219950(v=office.12)
    """  # noqa: E501

    APP_DISPATCH = "Outlook.Application"

    def send_email(
        self,
        recipients: Union[str, List[str]],
        subject: str,
        body: str,
        html_body: bool = False,
        attachments: Optional[Union[str, List[str]]] = None,
        save_as_draft: bool = False,
        cc_recipients: Optional[Union[str, List[str]]] = None,
        bcc_recipients: Optional[Union[str, List[str]]] = None,
        reply_to: Optional[Union[str, List[str]]] = None,
        check_names: bool = False,
    ) -> bool:
        """Send email with Outlook

        :param recipients: list of addresses
        :param subject: email subject
        :param body: email body
        :param html_body: True if body contains HTML, defaults to False
        :param attachments: list of filepaths to include in the email, defaults to []
        :param save_as_draft: email is saved as draft when `True`
        :param cc_recipients: list of addresses for CC field, default None
        :param bcc_recipients: list of addresses for BCC field, default None
        :param reply_to: list of addresses for changing email's reply-to field,
         default None
        :param check_names: all recipients are checked if the email address is
         recognized on `True`, default `False`
        :return: `True` if there were no errors

        Example:

        .. code-block:: python

            library = Outlook()
            library.open_application()
            cc_recipients = ["recipient3@domain.com","recipient4@domain.com"]
            library.send_email(
                recipients="recipient1@domain.com",
                cc_recipients=cc_recipients,
                bcc_recipients="recipient3@domain.com;recipient4@domain.com",
                subject="hello from Outlook",
                body="empty body",
                attachments=os.path.join(os.path.curdir, "example.xslx")
            )

        .. code-block:: robotframework

            ${cc}=  Create List   recipient3@domain.com   recipient4@domain.com
            Send Email
            ...    recipients=recipient1@domain.com
            ...    cc_repients=${cc}
            ...    bcc_repients=recipient5@domain.com;recipient6@domain.com
            ...    subject=hello from Outlook
            ...    body=empty body
            ...    attachments=${CURDIR}${/}example.xlsx
        """
        # pylint: disable=no-member
        attachments = attachments or []
        if not isinstance(attachments, list):
            attachments = str(attachments).split(";")

        try:
            mail = self.app.CreateItem(0)
            mail.Subject = subject
            self._add_all_recipients(
                mail, recipients, cc_recipients, bcc_recipients, reply_to, check_names
            )

            if html_body:
                mail.HTMLBody = body
            else:
                mail.Body = body

            self._add_attachments(mail, attachments)

            if save_as_draft:
                mail.Save()
                self.logger.debug("Email draft saved")
            else:
                mail.Send()
                self.logger.debug("Email sent")
        # On non-Windows OS `COMError` is `Exception`.
        except COMError as exc:  # pylint: disable=broad-except
            self.logger.error(
                f"Mail {'saving' if save_as_draft else 'sending'} failed: %s", exc
            )
            return False
        return True

    def _add_all_recipients(
        self, email, recipients, cc_recipients, bcc_recipients, reply_to, check_names
    ):
        invalid_names = []

        self._recipients_to_email(
            email, invalid_names, check_names, recipients, RECIPIENT_TYPE_TO
        )
        self._recipients_to_email(
            email, invalid_names, check_names, cc_recipients, RECIPIENT_TYPE_CC
        )
        self._recipients_to_email(
            email, invalid_names, check_names, bcc_recipients, RECIPIENT_TYPE_BCC
        )

        if len(invalid_names) > 0:
            raise InvalidNamesError(
                f"Failed to resolve recipient(s): {';'.join(invalid_names)}"
            )

        reply_to = self._recipients_to_list(reply_to)
        for recipient in reply_to:
            email.ReplyRecipients.Add(recipient)

    def _recipients_to_email(
        self, email, invalid_names, check_names, recipients, recipient_type
    ):
        recipients = self._recipients_to_list(recipients)
        for recipient in recipients:
            rec = email.Recipients.Add(recipient)
            # 1 = To, 2 = CC, 3 = BCC
            rec.Type = recipient_type
            if check_names:
                result = rec.Resolve()  # Attempt to resolve each recipient
                if not result:
                    invalid_names.append(recipient)

    def _recipients_to_list(self, recipients):
        recipients = recipients or []
        recipients = (
            recipients if isinstance(recipients, list) else recipients.split(";")
        )
        return recipients

    def _add_attachments(self, email, attachments):
        for attachment in attachments:
            filepath = Path(attachment).absolute()
            email.Attachments.Add(str(filepath))

    def _is_email_too_old(self, email):
        now = datetime.now().timestamp()
        msg_time = email["ReceivedTimestamp"]
        timediff = now - msg_time
        return timediff > 30.0

    def _check_for_matching(self, criterion, email):
        crit_key, crit_val = criterion.split(":", 1)
        crit_key = crit_key.upper()
        crit_val = crit_val.lower()
        match_found = None

        if crit_key.upper() == "SUBJECT" and crit_val in email["Subject"].lower():
            match_found = email
        elif crit_key.upper() == "SENDER" and crit_val in email["Sender"].lower():
            match_found = email
        elif crit_key.upper() == "BODY" and crit_val in email["Body"].lower():
            match_found = email

        return match_found

    def wait_for_email(
        self, criterion: str = None, timeout: float = 5.0, interval: float = 1.0
    ) -> Any:
        """Wait for email matching `criterion` to arrive into mailbox.

        :param criterion: email filter to wait for, defaults to ""
        :param timeout: total time in seconds to wait for email, defaults to 5.0
        :param interval: time in seconds for new check, defaults to 1.0
        :return: list of messages or False

        Possible wait criterias are: SUBJECT, SENDER and BODY

        Example:

        .. code-block:: robotframework

            Wait for Email     SUBJECT:rpa task calling    timeout=300    interval=10
        """
        if criterion is None:
            self.logger.warning(
                "Wait for message requires criteria for which message to wait for."
            )
            return False
        end_time = time.time() + float(timeout)
        namespace = self.app.GetNamespace("MAPI")
        inbox = namespace.GetDefaultFolder(6)
        while time.time() < end_time:
            messages = inbox.Items
            messages.Sort("[ReceivedTime]", True)
            for msg in messages:
                if type(msg).__name__ != "_MailItem":
                    continue
                m = self._mail_item_to_dict(msg)
                if self._is_email_too_old(m):
                    break
                else:
                    match_found = self._check_for_matching(criterion, m)
                    if match_found:
                        return match_found
            time.sleep(interval)
        raise AssertionError("Did not find matching message in the Outlook inbox")

    def get_emails(
        self,
        account_name: str = None,
        folder_name: str = None,
        email_filter: str = None,
        save_attachments: bool = False,
        attachment_folder: str = None,
        sort: bool = False,
        sort_key: str = None,
        sort_descending: bool = True,
    ) -> List:
        """Get emails from a specified email folder. Can be used to save attachments.

        :param account_name: needs to be given if there are shared accounts in use,
         defaults to None
        :param folder_name: target folder where to get emails from, default Inbox
        :param email_filter: how to filter email, default no filter,
         ie. all emails in folder
        :param save_attachments: if attachments should be saved, defaults to False
        :param attachment_folder: target folder where attachments are saved,
         defaults to current directory
        :param sort: if emails should be sorted, defaults to False
        :param sort_key: needs to be given if emails are to be sorted
        :param sort_descending: set to False for ascending sort, defaults to True
        :return: list of emails (list of dictionaries)

        Example:

        .. code-block:: robotframework

            ${emails}=  Get Emails
            ...    email_folder=priority
            ...    email_filter=[Subject]='incoming order'
            ...    save_attachments=True
            ...    attachment_folder=%{ROBOT_ROOT}${/}attachments
            ...    sort=True
            ...    sort_key=Received
            ...    sort_descending=False
        """
        folder = self._get_folder(account_name, folder_name)
        folder_messages = folder.Items if folder else []
        messages = []
        if folder_messages and email_filter:
            try:
                folder_messages = folder_messages.Restrict(email_filter)
            except Exception:  # pylint: disable=broad-except
                raise AttributeError(  # pylint: disable=raise-missing-from
                    "Invalid email filter '%s'" % email_filter
                )
        if folder_messages and sort:
            sort_key = sort_key or "ReceivedTime"
            try:
                folder_messages.Sort(f"[{sort_key}]", sort_descending)
            except Exception:  # pylint: disable=broad-except
                raise AttributeError(  # pylint: disable=raise-missing-from
                    "Invalid email sort key '%s'" % sort_key
                )
        for message in folder_messages:
            if type(message).__name__ != "_MailItem":
                continue
            if save_attachments:
                self.save_email_attachments(message.Attachments, attachment_folder)
            messages.append(self._mail_item_to_dict(message))
        return messages

    def _get_folder(self, account_name, email_folder):
        namespace = self.app.GetNamespace("MAPI")
        if not account_name and not email_folder:
            self.logger.warning("Getting items from default account inbox")
            return namespace.GetDefaultFolder(6)
        email_folder = email_folder or "Inbox"
        if account_name:
            account_folder = self._get_account_folder(namespace, account_name)
            if account_folder:
                folder = self._get_matching_folder(email_folder, account_folder)
            else:
                raise AttributeError("Did not find account by name '%s'" % account_name)
        else:
            folder = self._get_matching_folder(email_folder, None)
        return folder if folder else []

    def _get_account_folder(self, namespace, account_name):
        account_folder = None
        for f in namespace.Folders:
            if f.Name == account_name:
                account_folder = f
                break
        return account_folder

    def _get_matching_folder(self, folder_name, folder=None):
        if not folder or isinstance(folder, str):
            folders = self.app.GetNamespace("MAPI").Folders
        elif isinstance(folder, list):
            folders = folder
        else:
            folders = folder.Folders
        for f in folders:
            if folder_name == f.Name:
                self.logger.debug("Found matching folder: %s", f.Name)
                return f
            emails = self._get_matching_folder(folder_name, f)
            if emails:
                return emails
        return None

    def save_email_attachments(
        self, attachments: Any, attachment_folder: str, overwrite: bool = False
    ) -> None:
        """Save email attachments.

        Note. Keyword "Get Emails" can be also used to save attachments.

        :param attachments: all attachments from email or single attachment
        :param attachment_folder: target folder where attachments are saved,
            defaults to current directory
        :param overwrite: overwrite existing file if True, defaults to False

        Example:

        .. code-block:: robotframework

            ${emails} =  Get Emails
            ...    email_folder=priority
            FOR  ${email}  IN   @{emails}
                FOR  ${attachment}  IN  @{email}[Attachments]
                    IF  ${attachment}[size] < 100000   # bytes
                        Save Email Attachments
                        ...  ${attachment}
                        ...  ${CURDIR}${/}attachments
                    ELSE IF  ".pdf" in "${attachment}[filename]"
                        Save Email Attachments
                        ...  ${attachment}
                        ...  ${CURDIR}${/}attachments${/}pdf
                    END
                END
            END
        """
        attachment_target = Path(attachment_folder) if attachment_folder else Path(".")
        if isinstance(attachments, dict):
            email_attachments = [attachments["item"]]
        else:
            email_attachments = attachments
        for attachment in email_attachments:
            file_path = (attachment_target / attachment.FileName).absolute()
            if not overwrite:
                file_path = counter_duplicate_path(file_path)
            attachment.SaveAsFile(file_path)

    def mark_email_as_read(self, email: Any, read: bool = True) -> None:
        """Mark email 'read' property. Can be used to mark email as unread.

        :param email: target email
        :param read: True marks email as Read, False as Unread

        Example:

        .. code-block:: robotframework

            ${emails}=  Get Emails
            # Mark all as read
            FOR  ${email}  IN  @{emails}
                Mark Email As Read  ${email}
            END

            # Mark all as unread
            FOR  ${email}  IN  @{emails}
                Mark Email As Read  ${email}  False
            END
        """
        read_value = not read
        if type(email).__name__ == "_MailItem":
            email.UnRead = read_value
            email.Save()
        elif isinstance(email, dict):
            email["object"].UnRead = read_value
            email["object"].Save()

    def move_emails(
        self,
        account_name: str = None,
        source_folder: str = None,
        email_filter: Any = None,
        target_folder: str = None,
        mark_as_read: bool = True,
    ) -> bool:
        """Move emails from source folder to target folder.

        Use of "account_name" is recommended if there are shared accounts in use.

        :param account_name: needs to be given if there are shared accounts in use,
         defaults to None
        :param source_folder: folder where source emails exist
        :param email_filter: how to filter email, default no filter,
         ie. all emails in folder
        :param target_folder: folder where emails are moved into
        :param mark_as_read: mark emails as read after move, defaults to True
        :return: True if move operation was success, False if not

        Python example.

        .. code-block:: python

            outlook = RPA.Outlook.Application()

            # moving messages from Inbox to target_folder
            outlook.move_emails(
                target_folder='Processed Invoices',
                email_filter="[Subject]='incoming invoice'"
            )

            # moving messages from source_folder to target_folder
            outlook.move_emails(
                source_folder='Incoming Invoices',
                target_folder='Processed Invoices',
                email_filter="[Subject]='incoming invoice'"
            )

            # move message objects from `get_emails` result
            emails = outlook.get_emails("[Subject]='incoming invoice'")
            outlook.move_emails(
                target_folder='Processed Invoices',
                email_filter=emails
            )

        Robot Framework example.

        .. code-block:: robotframework

            # moving messages from Inbox to target_folder
            Move Emails
            ...    target_folder=Processed Invoices
            ...    email_filter=[Subject]='incoming invoice'

            # moving messages from source_folder to target_folder
            Move Emails
            ...    source_folder=Incoming Invoices
            ...    target_folder=Processed Invoices
            ...    email_filter=[Subject]='incoming invoice'

            # moving message objects from `Get Emails` result
            ${emails}=    Get Emails    [Subject]='incoming invoice'
            Move Emails
            ...    target_folder=Processed Invoices
            ...    email_filter=${emails}
        """
        if not target_folder:
            raise AttributeError("Can't move emails without target_folder")
        tf = self._get_folder(account_name, target_folder)
        if not tf or tf.Name != target_folder:
            raise AttributeError("Did not find target folder")
        self.logger.info("Found target folder: %s", tf.Name)
        if email_filter and not isinstance(email_filter, str):
            folder_messages = (
                email_filter if isinstance(email_filter, list) else [email_filter]
            )
        else:
            folder = self._get_folder(account_name, source_folder)
            folder_messages = folder.Items if folder else []
            try:
                folder_messages = folder_messages.Restrict(email_filter)
            except Exception:  # pylint: disable=broad-except
                raise AttributeError(  # pylint: disable=raise-missing-from
                    "Invalid email filter '%s'" % email_filter
                )
        if not folder_messages or len(folder_messages) == 0:
            self.logger.warning("Did not find emails to move")
            return False
        self.logger.info("Found %d emails to move", len(folder_messages))
        for m in folder_messages:
            m = m["object"] if isinstance(m, dict) else m
            if mark_as_read:
                m.UnRead = False
            m.Move(tf)
        return True

    def _mail_item_to_dict(self, mail_item):
        mi = mail_item
        response = {
            "Sender": self._get_sender_email_address(mi),
            "To": [],
            "CC": [],
            "BCC": [],
            "Subject": mi.Subject,
            "Body": mi.Body,
            "Attachments": [
                {"filename": a.FileName, "size": a.Size, "item": a}
                for a in mi.Attachments
            ],
            "Size": mi.Size,
            "object": mi,
        }
        rt = getattr(mail_item, "ReceivedTime", "<UNKNOWN>")
        response["ReceivedTime"] = rt.isoformat() if rt != "<UNKNOWN>" else rt
        response["ReceivedTimestamp"] = (
            datetime(
                rt.year, rt.month, rt.day, rt.hour, rt.minute, rt.second
            ).timestamp()
            if rt
            else None
        )
        so = getattr(mail_item, "SentOn", "<UNKNOWN>")
        response["SentOn"] = so.isoformat() if so != "<UNKNOWN>" else so
        self._handle_recipients(mi.Recipients, response)
        return response

    def _get_recipient_email_address(self, recipient):
        email_address = None
        try:
            email_address = recipient.AddressEntry.GetExchangeUser().PrimarySmtpAddress
        except Exception:  # pylint: disable=broad-except
            email_address = recipient.Address
        return email_address

    def _handle_recipients(self, recipients, response):
        for r in recipients:
            if r.Type == 1:
                response["To"].append(
                    {
                        "name": r.Name,
                        "email": self._get_recipient_email_address(r),
                    }
                )
            elif r.Type == 2:
                response["CC"].append(
                    {
                        "name": r.Name,
                        "email": self._get_recipient_email_address(r),
                    }
                )
            elif r.Type == 3:
                response["BCC"].append(
                    {
                        "name": r.Name,
                        "email": self._get_recipient_email_address(r),
                    }
                )

    def _get_sender_email_address(self, mail_item):
        mi = mail_item
        return (
            mi.Sender.GetExchangeUser().PrimarySmtpAddress
            if mi.SenderEmailType == "EX"
            else mi.SenderEmailAddress
        )

    def _address_entry_to_dict(self, address_entry):
        ae = address_entry
        return {"Name": ae.Name, "Address": ae.Address}
